diff --git a/.gitignore b/.gitignore index 629979d53..e0d523dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -173,6 +173,8 @@ src/dev-ui/.output/ certs/ .instances/ +.kartograph/backups/ +.kartograph/kg-backups/ # Demo web interface (not for production) demo-web/ diff --git a/AGENTS.md b/AGENTS.md index 38e3da92d..1a72bfd7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,9 +127,30 @@ must be triggered explicitly. **Standard (single developer):** ```bash make dev # starts all services: Postgres, SpiceDB, Keycloak, API, Dev UI -make down # tears everything down +make down # stops containers; Postgres data volume is preserved ``` +**Dev data backup / restore** (knowledge graphs, ontology, graph data, IAM, SpiceDB): + +```bash +make dev-backup # snapshot DBs to .kartograph/backups// +make dev-backup-list # list available snapshots +make dev-restore # restore the latest snapshot (prompts for confirmation) +make dev-restore BACKUP=2026-06-12T20-10-33Z +make dev-repair-age-graphs # fix corrupt AGE graphs without full DB restore +``` + +For isolated instances, set the compose project name: + +```bash +COMPOSE_PROJECT=kg-my-feature ./scripts/dev-data-backup.sh backup +COMPOSE_PROJECT=kg-my-feature ./scripts/dev-data-backup.sh restore latest --yes +``` + +Avoid `docker compose down -v` unless you intend to wipe volumes. After a +restore, if the dev UI shows an empty tenant, delete `~/.kartograph/token.json` +and sign in again. + **Isolated instance (agents / worktrees):** When working in a worktree or running multiple instances in parallel, diff --git a/Makefile b/Makefile index bda68a19e..7e8871b1c 100755 --- a/Makefile +++ b/Makefile @@ -23,8 +23,10 @@ certs: .PHONY: dev dev: certs @echo "🧰 [Development] Starting application containers..." + @./scripts/cleanup-openshell-sandboxes.sh + docker compose -f compose.yaml -f compose.dev.yaml --profile build-only build agent-runtime docker compose -f compose.yaml build - docker compose -f compose.yaml -f compose.dev.yaml --profile ui up -d + HOST_UID=$$(id -u) HOST_GID=$$(id -g) docker compose -f compose.yaml -f compose.dev.yaml --profile ui up -d @echo "Done." @echo "----------------------------" @echo "API Root: http://localhost:8000" @@ -35,6 +37,37 @@ dev: certs .PHONY: down down: docker compose -f compose.yaml -f compose.dev.yaml down + @echo "Stopping Graph Management sticky, worker, and extraction job containers..." + -@docker ps -aq --filter name=kartograph-sticky- | xargs -r docker rm -f + -@docker ps -aq --filter name=kartograph-worker- | xargs -r docker rm -f + -@docker ps -aq --filter name=kartograph-extract- | xargs -r docker rm -f + -@./scripts/cleanup-openshell-sandboxes.sh + +.PHONY: dev-backup dev-restore dev-backup-list dev-repair-age-graphs +dev-backup: + @./scripts/dev-data-backup.sh backup + +dev-restore: + @./scripts/dev-data-backup.sh restore $(or $(BACKUP),latest) + +dev-backup-list: + @./scripts/dev-data-backup.sh list + +dev-repair-age-graphs: + @./scripts/dev-data-backup.sh repair + +.PHONY: kg-backup kg-restore kg-backup-list +kg-backup: + @test -n "$(KG_ID)" || (echo "Usage: make kg-backup KG_ID=" && exit 1) + @./scripts/kg-data-backup.sh capture "$(KG_ID)" + +kg-restore: + @test -n "$(KG_ID)" || (echo "Usage: make kg-restore KG_ID= [BACKUP=latest] [YES=1] [REPLACE=1]" && exit 1) + @./scripts/kg-data-backup.sh restore "$(KG_ID)" $(or $(BACKUP),latest) $(if $(YES),--yes,) $(if $(REPLACE),--replace,) + +kg-backup-list: + @test -n "$(KG_ID)" || (echo "Usage: make kg-backup-list KG_ID=" && exit 1) + @./scripts/kg-data-backup.sh list "$(KG_ID)" .PHONY: run diff --git a/compose.dev.yaml b/compose.dev.yaml index e70679ff7..f687c3a13 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -1,19 +1,85 @@ # Development overrides for compose.yaml services: + agent-runtime: + build: + context: ./src/agent-runtime + dockerfile: Dockerfile + image: kartograph-agent-runtime:dev + profiles: [ "build-only" ] + api: - # Run as root in dev to handle host file permissions (any umask) - user: "${UID}:${GID}" + # Root required for Docker-out-of-Docker via mounted /var/run/docker.sock in dev + user: "0:0" environment: UV_CACHE_DIR: /tmp/uv-cache + HOST_UID: ${HOST_UID} + HOST_GID: ${HOST_GID} + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_ENGINE: auto + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_NETWORK: kartograph_kartograph + KARTOGRAPH_EXTRACTION_RUNTIME_STICKY_IMAGE: kartograph-agent-runtime:dev + KARTOGRAPH_EXTRACTION_RUNTIME_API_BASE_URL: http://api:8000 + KARTOGRAPH_EXTRACTION_RUNTIME_WORKLOAD_TOKEN_SIGNING_KEY: kartograph-dev-workload-token-signing-key + KARTOGRAPH_EXTRACTION_RUNTIME_JOB_PACKAGE_WORK_DIR: /tmp/kartograph/job_packages + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_RUN_UID: ${HOST_UID} + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_RUN_GID: ${HOST_GID} + KARTOGRAPH_EXTRACTION_RUNTIME_STICKY_TURN_TIMEOUT_SECONDS: "3600" + KARTOGRAPH_EXTRACTION_RUNTIME_STICKY_MAX_TURNS: "500" + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_HARDENING_ENABLED: "true" + ## Docker (Track A): + # KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND: container + # KARTOGRAPH_EXTRACTION_RUNTIME_JOB_RUNNER: agentic_ci + ## OpenShell (Track B): uncomment backend/job_runner after `openshell gateway add` on the host + KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND: openshell + KARTOGRAPH_EXTRACTION_RUNTIME_JOB_RUNNER: openshell + ## End Track A / Track B selection + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_GATEWAY_URL: https://host.docker.internal:17670 + # Forwards run inside this container; bind to 127.0.0.1 here, not on the host. + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_RUNTIME_HOST: 127.0.0.1 + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_API_BASE_URL: http://host.docker.internal:8000 + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_POLICY_DIR: /etc/openshell/policies + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_GATEWAY_NAME: openshell + # OpenShell CLI in the API container reads host gateway registration + mTLS from here + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_XDG_CONFIG_HOME: /root/.config + # Host gateway must listen beyond localhost — in ~/.config/openshell/gateway.toml: + # bind_address = "0.0.0.0:17670" + # then: systemctl --user restart openshell-gateway + KARTOGRAPH_EXTRACTION_RUNTIME_AGENTIC_CI_IMAGE: ghcr.io/opendatahub-io/ai-helpers:latest + # OpenShell extraction jobs use agentic-ci claude-sandbox (not ai-helpers or sticky runtime). + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_EXTRACTION_IMAGE: quay.io/aipcc/agentic-ci/claude-sandbox:latest + KARTOGRAPH_EXTRACTION_RUNTIME_AGENTIC_CI_HARNESS: claude-code + KARTOGRAPH_EXTRACTION_RUNTIME_EXTRACTION_JOB_WORK_DIR: /tmp/kartograph/extraction_jobs + # Vertex AI for Claude Agent SDK in sticky assistant containers + CLAUDE_CODE_USE_VERTEX: "1" + ANTHROPIC_VERTEX_PROJECT_ID: itpc-gcp-hcm-pe-eng-claude + CLOUD_ML_REGION: us-east5 + KARTOGRAPH_GCLOUD_CONFIG_MOUNT: ${HOME}/.config/gcloud volumes: # Mount the entire app directory (minus venv) for hot-reload - ./src/api:/app:z - /app/.venv + # Shared with sibling sticky containers launched via the host Docker socket + - /tmp/kartograph/job_packages:/tmp/kartograph/job_packages + - /tmp/kartograph/extraction_jobs:/tmp/kartograph/extraction_jobs + # gcloud ADC for Vertex-backed agentic-ci extraction job containers + - ${HOME}/.config/gcloud:${HOME}/.config/gcloud:ro,z + # Allow API process to launch sibling extraction runtime containers locally + - /var/run/docker.sock:/var/run/docker.sock + # Docker/Podman CLI from host (required for container runtime backend) + - ${DOCKER_BIN:-/usr/bin/docker}:/usr/bin/docker:ro + # OpenShell CLI + mTLS config (host gateway; API container invokes openshell subprocess) + - /usr/bin/openshell:/usr/bin/openshell:ro + - ${HOME}/.config/openshell:/root/.config/openshell:ro,z + # forward start -d writes PID/state here; read-only parent mount hangs the CLI + - openshell-forwards:/root/.config/openshell/forwards + # OpenShell policy templates (Phase 3) when backend=openshell + - ./src/api/extraction/infrastructure/openshell/policies:/etc/openshell/policies:ro,z + extra_hosts: + - "host.docker.internal:host-gateway" command: - /bin/bash - -c - | - uv run fastapi dev main.py --host 0.0.0.0 --port 8000 + uv sync --frozen && uv run alembic upgrade head && uv run fastapi dev main.py --host 0.0.0.0 --port 8000 dev-ui: build: @@ -30,7 +96,10 @@ services: - /app/.output environment: - HOST=0.0.0.0 - command: ["pnpm", "run", "dev"] + command: [ "pnpm", "run", "dev" ] ports: - "3000:3000" - "24678:24678" + +volumes: + openshell-forwards: diff --git a/compose.yaml b/compose.yaml index 9e2d1d838..6ed15f7c2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -147,7 +147,7 @@ services: - ./certs:/certs:ro # Mount host CA bundle (supports multiple OS types via env var) # Default fallback order: RHEL/Fedora -> Debian/Ubuntu -> macOS - - ${HOST_CA_BUNDLE:-/etc/pki/tls/certs/ca-bundle.crt}:/etc/ssl/certs/ca-bundle.crt:ro + - ${HOST_CA_BUNDLE:-/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem}:/etc/ssl/certs/ca-bundle.crt:ro tty: true extra_hosts: - "localhost:host-gateway" diff --git a/deploy/apps/kartograph/base/api-deployment.yaml b/deploy/apps/kartograph/base/api-deployment.yaml index 1de0bc5ee..d036d53a4 100644 --- a/deploy/apps/kartograph/base/api-deployment.yaml +++ b/deploy/apps/kartograph/base/api-deployment.yaml @@ -155,11 +155,44 @@ spec: secretKeyRef: name: kartograph-sso-client-swagger-docs key: client_id + - name: KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND + valueFrom: + configMapKeyRef: + name: kartograph-config + key: KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND + optional: true + - name: KARTOGRAPH_EXTRACTION_RUNTIME_API_BASE_URL + valueFrom: + configMapKeyRef: + name: kartograph-config + key: KARTOGRAPH_EXTRACTION_RUNTIME_API_BASE_URL + optional: true + - name: KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_HARDENING_ENABLED + valueFrom: + configMapKeyRef: + name: kartograph-config + key: KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_HARDENING_ENABLED + optional: true + - name: KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_POLICY_DIR + valueFrom: + configMapKeyRef: + name: kartograph-config + key: KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_POLICY_DIR + optional: true + - name: KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_POLICY_ENFORCEMENT + valueFrom: + configMapKeyRef: + name: kartograph-config + key: KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_POLICY_ENFORCEMENT + optional: true volumeMounts: - name: spicedb-ca mountPath: /etc/spicedb-ca readOnly: true + - name: openshell-policies + mountPath: /etc/openshell/policies + readOnly: true livenessProbe: httpGet: path: /health @@ -190,3 +223,6 @@ spec: items: - key: service-ca.crt path: service-ca.crt + - name: openshell-policies + configMap: + name: kartograph-openshell-policies diff --git a/deploy/apps/kartograph/base/configmap.yaml b/deploy/apps/kartograph/base/configmap.yaml index 03c6c8fad..e08f3021e 100644 --- a/deploy/apps/kartograph/base/configmap.yaml +++ b/deploy/apps/kartograph/base/configmap.yaml @@ -22,3 +22,12 @@ data: DEV_UI_KEYCLOAK_URL: "http://keycloak:8080" DEV_UI_KEYCLOAK_REALM: "kartograph" DEV_UI_KEYCLOAK_CLIENT_ID: "kartograph-ui" + # Extraction runtime (container backend with Phase 0 hardening; switch to openshell in overlay) + KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND: "container" + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_NETWORK: "kartograph" + KARTOGRAPH_EXTRACTION_RUNTIME_API_BASE_URL: "http://kartograph-api:8000" + KARTOGRAPH_EXTRACTION_RUNTIME_AGENTIC_CI_API_BASE_URL: "http://kartograph-api:8000" + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_HARDENING_ENABLED: "true" + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_POLICY_DIR: "/etc/openshell/policies" + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_POLICY_ENFORCEMENT: "hard_requirement" + KARTOGRAPH_EXTRACTION_RUNTIME_OPENSHELL_RUNTIME_HOST: "127.0.0.1" diff --git a/deploy/apps/kartograph/base/kustomization.yaml b/deploy/apps/kartograph/base/kustomization.yaml index 4ffbd8134..d4709be85 100644 --- a/deploy/apps/kartograph/base/kustomization.yaml +++ b/deploy/apps/kartograph/base/kustomization.yaml @@ -28,6 +28,8 @@ resources: - configmap.yaml - spicedb-schema-configmap.yaml - spicedb-ca-configmap.yaml + - networkpolicy-sticky-runtime.yaml + - openshell-policies-configmap.yaml commonLabels: app.kubernetes.io/name: kartograph diff --git a/deploy/apps/kartograph/base/networkpolicy-sticky-runtime.yaml b/deploy/apps/kartograph/base/networkpolicy-sticky-runtime.yaml new file mode 100644 index 000000000..d227ac2eb --- /dev/null +++ b/deploy/apps/kartograph/base/networkpolicy-sticky-runtime.yaml @@ -0,0 +1,33 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: kartograph-sticky-runtime-egress + labels: + app.kubernetes.io/component: sticky-runtime +spec: + podSelector: + matchLabels: + kartograph.runtime.kind: sticky + policyTypes: + - Egress + egress: + # Kartograph workload API (in-cluster DNS name) + - to: + - podSelector: + matchLabels: + app.kubernetes.io/component: api + ports: + - protocol: TCP + port: 8000 + # DNS resolution inside the cluster + - to: + - namespaceSelector: {} + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Vertex / Anthropic inference via OpenShell inference.local routing when enabled + - ports: + - protocol: TCP + port: 443 diff --git a/deploy/apps/kartograph/base/openshell-policies-configmap.yaml b/deploy/apps/kartograph/base/openshell-policies-configmap.yaml new file mode 100644 index 000000000..c3f19113b --- /dev/null +++ b/deploy/apps/kartograph/base/openshell-policies-configmap.yaml @@ -0,0 +1,76 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: kartograph-openshell-policies + labels: + app.kubernetes.io/component: openshell +data: + gma-sticky-base.yaml: | + version: 1 + name: gma-sticky-base + enforcement: hard_requirement + endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" + l7_allowed_paths: + - "/extraction/workloads/*" + gma-initial-schema-design.yaml: | + version: 1 + name: gma-initial-schema-design + enforcement: hard_requirement + endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" + l7_allowed_paths: + - "/extraction/workloads/schema/*" + - "/extraction/workloads/graph/*" + gma-extraction-jobs.yaml: | + version: 1 + name: gma-extraction-jobs + enforcement: hard_requirement + endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" + l7_allowed_paths: + - "/extraction/workloads/jobs/*" + - "/extraction/workloads/schema/*" + - "/extraction/workloads/graph/*" + gma-one-off-mutations.yaml: | + version: 1 + name: gma-one-off-mutations + enforcement: hard_requirement + endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" + l7_allowed_paths: + - "/extraction/workloads/mutations/*" + - "/extraction/workloads/graph/*" + extraction-job.yaml: | + version: 1 + name: extraction-job + enforcement: hard_requirement + endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "github.com:443:full" + - "*.github.com:443:full" + - "pypi.org:443:read-only" + - "files.pythonhosted.org:443:read-only" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" + - "api.anthropic.com:443:read-write" + l7_allowed_paths: + - "/extraction/workloads/*" diff --git a/deploy/openshell/policies/extraction-job.yaml b/deploy/openshell/policies/extraction-job.yaml new file mode 100644 index 000000000..6c5aaca75 --- /dev/null +++ b/deploy/openshell/policies/extraction-job.yaml @@ -0,0 +1,17 @@ +version: 1 +name: extraction-job +enforcement: hard_requirement +description: Production network policy for batch extraction jobs. +endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "github.com:443:full" + - "*.github.com:443:full" + - "pypi.org:443:read-only" + - "files.pythonhosted.org:443:read-only" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" + - "api.anthropic.com:443:read-write" +l7_allowed_paths: + - "/extraction/workloads/*" diff --git a/deploy/openshell/policies/gma-extraction-jobs.yaml b/deploy/openshell/policies/gma-extraction-jobs.yaml new file mode 100644 index 000000000..c0ae98a40 --- /dev/null +++ b/deploy/openshell/policies/gma-extraction-jobs.yaml @@ -0,0 +1,14 @@ +version: 1 +name: gma-extraction-jobs +enforcement: hard_requirement +description: Production policy for extraction-jobs graph-management mode. +endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" +l7_allowed_paths: + - "/extraction/workloads/jobs/*" + - "/extraction/workloads/schema/*" + - "/extraction/workloads/graph/*" diff --git a/deploy/openshell/policies/gma-initial-schema-design.yaml b/deploy/openshell/policies/gma-initial-schema-design.yaml new file mode 100644 index 000000000..911a2fad7 --- /dev/null +++ b/deploy/openshell/policies/gma-initial-schema-design.yaml @@ -0,0 +1,13 @@ +version: 1 +name: gma-initial-schema-design +enforcement: hard_requirement +description: Production policy for initial schema design mode. +endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" +l7_allowed_paths: + - "/extraction/workloads/schema/*" + - "/extraction/workloads/graph/*" diff --git a/deploy/openshell/policies/gma-one-off-mutations.yaml b/deploy/openshell/policies/gma-one-off-mutations.yaml new file mode 100644 index 000000000..71c712c90 --- /dev/null +++ b/deploy/openshell/policies/gma-one-off-mutations.yaml @@ -0,0 +1,13 @@ +version: 1 +name: gma-one-off-mutations +enforcement: hard_requirement +description: Production policy for one-off graph mutations mode. +endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" +l7_allowed_paths: + - "/extraction/workloads/mutations/*" + - "/extraction/workloads/graph/*" diff --git a/deploy/openshell/policies/gma-sticky-base.yaml b/deploy/openshell/policies/gma-sticky-base.yaml new file mode 100644 index 000000000..8dbcbb11a --- /dev/null +++ b/deploy/openshell/policies/gma-sticky-base.yaml @@ -0,0 +1,12 @@ +version: 1 +name: gma-sticky-base +enforcement: hard_requirement +description: Production base network policy for graph-management sticky sessions. +endpoints: + - "kartograph-api:8000:read-write" + - "inference.local:443:read-write" + - "aiplatform.googleapis.com:443:read-write" + - "*.aiplatform.googleapis.com:443:read-write" + - "oauth2.googleapis.com:443:read-write" +l7_allowed_paths: + - "/extraction/workloads/*" diff --git a/env/api.env b/env/api.env index c909d14cf..6cdd3da20 100644 --- a/env/api.env +++ b/env/api.env @@ -11,5 +11,6 @@ SPICEDB_PRESHARED_KEY="changeme" KARTOGRAPH_CORS_ORIGINS=["http://localhost:3000"] KARTOGRAPH_IAM_BOOTSTRAP_ADMIN_USERNAMES='["alice"]' KARTOGRAPH_IAM_SINGLE_TENANT_MODE=false -# Generate with uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -KARTOGRAPH_MGMT_ENCRYPTION_KEY="vwN4rUcH-KL-UyJsL8hc6apftRUTovwec6L2M5uF5OE=" \ No newline at end of file +KARTOGRAPH_MGMT_ENCRYPTION_KEY="vwN4rUcH-KL-UyJsL8hc6apftRUTovwec6L2M5uF5OE=" +KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND=memory +KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_ENGINE=auto \ No newline at end of file diff --git a/keycloak/realm.json b/keycloak/realm.json index 85bd32af7..209cdc656 100644 --- a/keycloak/realm.json +++ b/keycloak/realm.json @@ -76,6 +76,7 @@ ], "users": [ { + "id": "91bd9b81-5c1d-4307-8dcd-3b80dcc68894", "username": "alice", "enabled": true, "email": "alice@example.com", @@ -90,6 +91,7 @@ ] }, { + "id": "7ac7083e-42c8-4643-8b2f-052ffc579ea2", "username": "bob", "enabled": true, "email": "bob@example.com", diff --git a/scripts/cleanup-openshell-sandboxes.sh b/scripts/cleanup-openshell-sandboxes.sh new file mode 100755 index 000000000..b5375d5a2 --- /dev/null +++ b/scripts/cleanup-openshell-sandboxes.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Delete Kartograph-owned OpenShell sandboxes left over from sticky GMA sessions +# and extraction jobs (e.g. after make down without end-session). +# +# Safe to run when openshell is not installed or the gateway is down — exits 0. + +set -euo pipefail + +KARTOGRAPH_SANDBOX_PATTERN='^kartograph-(gma|extract)-' + +if ! command -v openshell >/dev/null 2>&1; then + echo "openshell not on PATH; skipping Kartograph sandbox cleanup" + exit 0 +fi + +names="$(openshell sandbox list --names 2>/dev/null | grep -E "$KARTOGRAPH_SANDBOX_PATTERN" || true)" +if [[ -z "${names// }" ]]; then + echo "No Kartograph OpenShell sandboxes to clean up" + exit 0 +fi + +echo "Cleaning up Kartograph OpenShell sandboxes..." +while IFS= read -r name; do + [[ -z "$name" ]] && continue + echo " → deleting $name" + openshell sandbox delete "$name" 2>/dev/null || echo " (delete failed or already gone: $name)" +done <<< "$names" +echo "OpenShell sandbox cleanup done." diff --git a/scripts/dev-data-backup.sh b/scripts/dev-data-backup.sh new file mode 100755 index 000000000..cb66d8b4e --- /dev/null +++ b/scripts/dev-data-backup.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +# +# Backup and restore Kartograph development databases (PostgreSQL). +# +# Captures the kartograph application database (metadata, ontology, AGE graph, +# IAM, outbox, etc.) and the spicedb authorization database. Optionally archives +# prepared JobPackage files from the host work dir. +# +# Usage: +# ./scripts/dev-data-backup.sh backup [--project ] +# ./scripts/dev-data-backup.sh restore [--project ] [backup-id|latest] [--yes] +# ./scripts/dev-data-backup.sh list +# +# Makefile shortcuts: make dev-backup, make dev-restore, make dev-backup-list +# +# Default compose project is "kartograph" (standard `make dev`). Isolated instances +# use project names like "kg-my-feature" from dev-instance.sh: +# COMPOSE_PROJECT=kg-my-feature ./scripts/dev-data-backup.sh backup + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKUP_ROOT="$REPO_ROOT/.kartograph/backups" + +COMPOSE_PROJECT="${COMPOSE_PROJECT:-kartograph}" +COMPOSE_FILES=(-f "$REPO_ROOT/compose.yaml" -f "$REPO_ROOT/compose.dev.yaml") +AUTO_CONFIRM=false +BACKUP_ID="" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/env/postgres.env" + +POSTGRES_USER="${POSTGRES_USER:-kartograph}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-kartograph_dev_password}" +APP_DATABASE="${POSTGRES_DB:-kartograph}" +AUTH_DATABASE="spicedb" + +usage() { + cat <<'EOF' +Usage: + dev-data-backup.sh backup [--project ] + dev-data-backup.sh restore [--project ] [backup-id|latest] [--yes] + dev-data-backup.sh repair [--project ] + dev-data-backup.sh list + +Environment: + COMPOSE_PROJECT Docker Compose project name (default: kartograph) + +Examples: + make dev-backup + make dev-restore + ./scripts/dev-data-backup.sh restore 2026-06-12T19-30-00Z + COMPOSE_PROJECT=kg-kartograph ./scripts/dev-data-backup.sh backup +EOF +} + +log() { + printf '%s\n' "$*" +} + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +compose() { + docker compose -p "$COMPOSE_PROJECT" "${COMPOSE_FILES[@]}" "$@" +} + +postgres_container_id() { + local container_id + container_id="$(compose ps -q postgres 2>/dev/null | head -n 1 || true)" + if [[ -z "$container_id" ]]; then + die "Postgres container not found for compose project '$COMPOSE_PROJECT'. Is 'make dev' running?" + fi + printf '%s' "$container_id" +} + +postgres_exec() { + local container_id + container_id="$(postgres_container_id)" + docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$container_id" "$@" +} + +git_commit_short() { + if git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo "unknown" + else + echo "unknown" + fi +} + +timestamp_utc() { + date -u +"%Y-%m-%dT%H-%M-%SZ" +} + +stop_dependent_services() { + log "Stopping API and SpiceDB to release database connections..." + compose stop api spicedb >/dev/null 2>&1 || true +} + +start_dependent_services() { + log "Starting API and SpiceDB..." + compose start spicedb api >/dev/null 2>&1 || compose up -d spicedb api +} + +terminate_db_connections() { + local database="$1" + postgres_exec psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 -c \ + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${database}' AND pid <> pg_backend_pid();" \ + >/dev/null +} + +dump_database() { + local database="$1" + local output_path="$2" + log " Dumping database '${database}'..." + postgres_exec pg_dump -U "$POSTGRES_USER" -d "$database" -Fc --no-owner --no-acl \ + >"$output_path" +} + +restore_database() { + local database="$1" + local dump_path="$2" + local container_id + container_id="$(postgres_container_id)" + log " Restoring database '${database}'..." + terminate_db_connections "$database" + # Stream the custom-format dump into the container (shell redirect via function breaks stdin). + docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$container_id" \ + pg_restore -U "$POSTGRES_USER" -d "$database" --clean --if-exists --no-owner --no-acl \ + <"$dump_path" +} + +maybe_backup_job_packages() { + local backup_dir="$1" + local source_dir="/tmp/kartograph/job_packages" + if [[ -d "$source_dir" ]] && [[ -n "$(ls -A "$source_dir" 2>/dev/null || true)" ]]; then + log " Archiving prepared JobPackages from ${source_dir}..." + tar -C "$(dirname "$source_dir")" -czf "$backup_dir/job_packages.tar.gz" "$(basename "$source_dir")" + fi +} + +maybe_restore_job_packages() { + local backup_dir="$1" + local archive="$backup_dir/job_packages.tar.gz" + if [[ -f "$archive" ]]; then + log " Restoring prepared JobPackages to /tmp/kartograph/..." + mkdir -p /tmp + tar -C /tmp -xzf "$archive" + fi +} + +write_manifest() { + local backup_dir="$1" + local created_at + created_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + cat >"$backup_dir/manifest.json" </dev/null | sort | tail -n 1)" + fi + elif [[ -d "$BACKUP_ROOT/$requested" ]]; then + backup_dir="$BACKUP_ROOT/$requested" + elif [[ -d "$requested" ]]; then + backup_dir="$requested" + fi + + if [[ -z "$backup_dir" || ! -d "$backup_dir" ]]; then + die "Backup not found: ${requested}. Run 'make dev-backup-list' to see available backups." + fi + if [[ ! -f "$backup_dir/kartograph.dump" || ! -f "$backup_dir/spicedb.dump" ]]; then + die "Backup '${backup_dir}' is missing required dump files." + fi + printf '%s' "$backup_dir" +} + +graph_is_queryable() { + local graph_name="$1" + postgres_exec psql -U "$POSTGRES_USER" -d "$APP_DATABASE" -v ON_ERROR_STOP=1 -c \ + "LOAD 'age'; SET search_path = ag_catalog, \"\$user\", public; SELECT * FROM cypher('${graph_name}', \$\$ RETURN 1 \$\$) as (x agtype);" \ + >/dev/null 2>&1 +} + +repair_tenant_age_graph() { + local graph_name="$1" + if graph_is_queryable "$graph_name"; then + return 0 + fi + log " Repairing AGE graph ${graph_name}..." + postgres_exec psql -U "$POSTGRES_USER" -d "$APP_DATABASE" -c \ + "LOAD 'age'; SET search_path = ag_catalog, \"\$user\", public; SELECT ag_catalog.drop_graph('${graph_name}', true);" \ + >/dev/null 2>&1 || true + postgres_exec psql -U "$POSTGRES_USER" -d "$APP_DATABASE" -c \ + "LOAD 'age'; SET search_path = ag_catalog, \"\$user\", public; SELECT ag_catalog.create_graph('${graph_name}');" +} + +repair_all_tenant_age_graphs() { + log "Ensuring tenant AGE graphs are queryable after restore..." + local graph_names="" + graph_names="$(postgres_exec psql -U "$POSTGRES_USER" -d "$APP_DATABASE" -Atc \ + "SELECT DISTINCT graph_name FROM ( + SELECT name AS graph_name FROM ag_catalog.ag_graph WHERE name LIKE 'tenant_%' + UNION + SELECT 'tenant_' || id AS graph_name FROM tenants + ) AS tenant_graphs ORDER BY graph_name")" + while IFS= read -r graph_name; do + [[ -z "$graph_name" ]] && continue + repair_tenant_age_graph "$graph_name" + done <<< "$graph_names" +} + +cmd_restore() { + local backup_dir + backup_dir="$(resolve_backup_dir "$BACKUP_ID")" + + if [[ "$AUTO_CONFIRM" != "true" ]]; then + log "This will REPLACE all data in databases '${APP_DATABASE}' and '${AUTH_DATABASE}'" + log "for compose project '${COMPOSE_PROJECT}' from:" + log " ${backup_dir}" + read -r -p "Continue? [y/N] " reply + case "$reply" in + y|Y|yes|YES) ;; + *) log "Aborted."; exit 1 ;; + esac + fi + + stop_dependent_services + log "Restoring dev backup from '${backup_dir}'..." + restore_database "$APP_DATABASE" "$backup_dir/kartograph.dump" + restore_database "$AUTH_DATABASE" "$backup_dir/spicedb.dump" + repair_all_tenant_age_graphs + maybe_restore_job_packages "$backup_dir" + start_dependent_services + + log "Restore complete." + log "If the dev UI shows an empty tenant, clear ~/.kartograph/token.json and sign in again." + log "Note: repaired AGE graphs start empty — re-run instance prepopulation if needed." +} + +cmd_list() { + if [[ ! -d "$BACKUP_ROOT" ]]; then + log "No backups yet. Run 'make dev-backup' first." + exit 0 + fi + log "Available backups in ${BACKUP_ROOT}:" + find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d ! -name 'latest' -printf '%f\n' 2>/dev/null \ + | sort -r \ + || true + if [[ -L "$BACKUP_ROOT/latest" ]]; then + log "" + log "latest -> $(readlink "$BACKUP_ROOT/latest")" + fi +} + +ACTION="${1:-}" +shift || true + +while [[ $# -gt 0 ]]; do + case "$1" in + --project) + COMPOSE_PROJECT="$2" + shift 2 + ;; + --yes|-y) + AUTO_CONFIRM=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [[ -z "$BACKUP_ID" ]]; then + BACKUP_ID="$1" + shift + else + die "Unknown argument: $1" + fi + ;; + esac +done + +case "$ACTION" in + backup) + cmd_backup + ;; + restore) + BACKUP_ID="${BACKUP_ID:-latest}" + cmd_restore + ;; + repair) + repair_all_tenant_age_graphs + log "AGE graph repair complete." + ;; + list) + cmd_list + ;; + ""|-h|--help|help) + usage + ;; + *) + die "Unknown command: ${ACTION}. Run with --help for usage." + ;; +esac diff --git a/scripts/export_system_properties.py b/scripts/export_system_properties.py index 2ddea5f4b..6140477b4 100644 --- a/scripts/export_system_properties.py +++ b/scripts/export_system_properties.py @@ -31,7 +31,7 @@ def export_system_properties(): # Property descriptions for documentation property_descriptions = { "data_source_id": "Identifies which data source this entity came from (e.g., 'ds-123')", - "source_path": "The file path within the data source where this entity was extracted from", + "source_path": "Optional provenance path within the data source (not a platform-required system property)", "slug": "Unique human-readable identifier for the node (e.g., 'alice-smith', 'kartograph')", } diff --git a/scripts/kg-data-backup.sh b/scripts/kg-data-backup.sh new file mode 100755 index 000000000..f930d7a10 --- /dev/null +++ b/scripts/kg-data-backup.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# Capture and restore a single Knowledge Graph from a Kartograph dev instance. +# +# Usage: +# ./scripts/kg-data-backup.sh capture +# ./scripts/kg-data-backup.sh restore [backup-id|latest] [--yes] [--replace] +# ./scripts/kg-data-backup.sh list +# +# Makefile shortcuts: +# make kg-backup KG_ID=01KTYN8Q0RJS2CCQX044S4V96C +# make kg-restore KG_ID=01KTYN8Q0RJS2CCQX044S4V96C +# make kg-backup-list KG_ID=01KTYN8Q0RJS2CCQX044S4V96C +# +# Backups are written to .kartograph/kg-backups/// (gitignored). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +COMPOSE_PROJECT="${COMPOSE_PROJECT:-kartograph}" + +usage() { + cat <<'EOF' +Usage: + kg-data-backup.sh capture + kg-data-backup.sh restore [backup-id|latest] [--yes] [--replace] + kg-data-backup.sh list + +Environment: + COMPOSE_PROJECT Docker compose project name (default: kartograph) + +Examples: + make kg-backup KG_ID=01KTYN8Q0RJS2CCQX044S4V96C + make kg-restore KG_ID=01KTYN8Q0RJS2CCQX044S4V96C BACKUP=latest + ./scripts/kg-data-backup.sh restore 01KTYN8Q0RJS2CCQX044S4V96C latest --replace --yes +EOF +} + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +postgres_container_id() { + local container_id + container_id="$( + docker compose -p "$COMPOSE_PROJECT" \ + -f "$REPO_ROOT/compose.yaml" \ + -f "$REPO_ROOT/compose.dev.yaml" \ + ps -q postgres 2>/dev/null | head -n 1 || true + )" + if [[ -z "$container_id" ]]; then + die "Postgres container not found for compose project '$COMPOSE_PROJECT'. Is 'make dev' running?" + fi + printf '%s' "$container_id" +} + +ensure_dev_postgres() { + postgres_container_id >/dev/null +} + +run_python() { + ensure_dev_postgres + cd "$REPO_ROOT/src/api" + COMPOSE_PROJECT="$COMPOSE_PROJECT" uv run python "$REPO_ROOT/scripts/kg_data_backup.py" "$@" +} + +ACTION="${1:-}" +shift || true + +case "$ACTION" in + capture|restore|list) + run_python "$ACTION" "$@" + ;; + ""|-h|--help|help) + usage + ;; + *) + die "Unknown command: ${ACTION}. Run with --help for usage." + ;; +esac diff --git a/scripts/kg_data_backup.py b/scripts/kg_data_backup.py new file mode 100644 index 000000000..6c49bea33 --- /dev/null +++ b/scripts/kg_data_backup.py @@ -0,0 +1,783 @@ +#!/usr/bin/env python3 +"""Capture and restore a single Knowledge Graph from a Kartograph dev instance. + +Backs up PostgreSQL metadata, encrypted credentials, Apache AGE graph data +(nodes and edges scoped by knowledge_graph_id), and SpiceDB authorization +tuples for the KG and its data sources. + +Usage: + uv run python scripts/kg_data_backup.py capture + uv run python scripts/kg_data_backup.py restore [backup-id|latest] --yes + uv run python scripts/kg_data_backup.py list + +Environment (defaults match `make dev` / env/postgres.env): + KARTOGRAPH_DB_HOST, KARTOGRAPH_DB_PORT, KARTOGRAPH_DB_DATABASE, + KARTOGRAPH_DB_USERNAME, KARTOGRAPH_DB_PASSWORD +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import psycopg2 +from psycopg2 import sql +from psycopg2.extras import RealDictCursor + +REPO_ROOT = Path(__file__).resolve().parent.parent +BACKUP_ROOT = REPO_ROOT / ".kartograph" / "kg-backups" +POSTGRES_ENV = REPO_ROOT / "env" / "postgres.env" +SPICEDB_ENV = REPO_ROOT / "env" / "spicedb.env" + +POSTGRES_TABLES_IN_RESTORE_ORDER = [ + "knowledge_graphs", + "encrypted_credentials", + "data_sources", + "data_source_sync_runs", + "knowledge_graph_type_definitions", + "extraction_runs", + "extraction_jobs", + "extraction_agent_sessions", +] + +ULID_PATTERN = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$") + + +def load_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.exists(): + return values + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def db_settings() -> dict[str, str]: + file_values = load_env_file(POSTGRES_ENV) + host = os.getenv("KARTOGRAPH_DB_HOST", file_values.get("POSTGRES_HOST", "localhost")) + if host == "postgres": + host = "localhost" + return { + "host": host, + "port": os.getenv("KARTOGRAPH_DB_PORT", file_values.get("POSTGRES_PORT", "5432")), + "database": os.getenv( + "KARTOGRAPH_DB_DATABASE", file_values.get("POSTGRES_DB", "kartograph") + ), + "user": os.getenv( + "KARTOGRAPH_DB_USERNAME", file_values.get("POSTGRES_USER", "kartograph") + ), + "password": os.getenv( + "KARTOGRAPH_DB_PASSWORD", file_values.get("POSTGRES_PASSWORD", "") + ), + } + + +def spicedb_database_name() -> str: + return "spicedb" + + +def connect(database: str | None = None) -> psycopg2.extensions.connection: + settings = db_settings() + return psycopg2.connect( + host=settings["host"], + port=settings["port"], + dbname=database or settings["database"], + user=settings["user"], + password=settings["password"], + ) + + +def timestamp_utc() -> str: + return datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%SZ") + + +def git_commit_short() -> str: + try: + result = subprocess.run( + ["git", "-C", str(REPO_ROOT), "rev-parse", "--short", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() or "unknown" + except (OSError, subprocess.CalledProcessError): + return "unknown" + + +def validate_kg_id(kg_id: str) -> None: + if not ULID_PATTERN.match(kg_id): + raise SystemExit(f"Invalid knowledge graph id: {kg_id}") + + +def json_default(value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, memoryview): + return base64.b64encode(value.tobytes()).decode("ascii") + if isinstance(value, (bytes, bytearray)): + return base64.b64encode(bytes(value)).decode("ascii") + raise TypeError(f"Object of type {type(value)!r} is not JSON serializable") + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, indent=2, sort_keys=True, default=json_default), + encoding="utf-8", + ) + + +def read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def agtype_to_python(value: Any) -> Any: + if value is None: + return None + if isinstance(value, str): + text = value.strip() + if text.startswith("{") or text.startswith("["): + try: + return json.loads(text) + except json.JSONDecodeError: + return text.strip('"') + return text.strip('"') + return value + + +def fetch_kg_metadata(conn: psycopg2.extensions.connection, kg_id: str) -> dict[str, Any]: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + "SELECT * FROM knowledge_graphs WHERE id = %s", + (kg_id,), + ) + row = cur.fetchone() + if row is None: + raise SystemExit(f"Knowledge graph not found: {kg_id}") + return dict(row) + + +def fetch_table_rows( + conn: psycopg2.extensions.connection, + table: str, + *, + where_sql: str, + params: tuple[Any, ...], +) -> list[dict[str, Any]]: + query = sql.SQL("SELECT * FROM {} WHERE ").format(sql.Identifier(table)) + sql.SQL(where_sql) + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +def fetch_postgres_payload( + conn: psycopg2.extensions.connection, kg_id: str, tenant_id: str +) -> dict[str, list[dict[str, Any]]]: + data_sources = fetch_table_rows( + conn, + "data_sources", + where_sql="knowledge_graph_id = %s", + params=(kg_id,), + ) + data_source_ids = tuple(row["id"] for row in data_sources) + credential_paths = tuple( + row["credentials_path"] for row in data_sources if row.get("credentials_path") + ) + + sync_runs: list[dict[str, Any]] = [] + if data_source_ids: + placeholders = sql.SQL(", ").join(sql.Placeholder() * len(data_source_ids)) + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + sql.SQL("SELECT * FROM data_source_sync_runs WHERE data_source_id IN ({})").format( + placeholders + ), + data_source_ids, + ) + sync_runs = [dict(row) for row in cur.fetchall()] + + credentials: list[dict[str, Any]] = [] + if credential_paths: + placeholders = sql.SQL(", ").join(sql.Placeholder() * len(credential_paths)) + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + sql.SQL( + "SELECT * FROM encrypted_credentials WHERE tenant_id = %s AND path IN ({})" + ).format(placeholders), + (tenant_id, *credential_paths), + ) + credentials = [dict(row) for row in cur.fetchall()] + + return { + "knowledge_graphs": fetch_table_rows( + conn, "knowledge_graphs", where_sql="id = %s", params=(kg_id,) + ), + "data_sources": data_sources, + "data_source_sync_runs": sync_runs, + "knowledge_graph_type_definitions": fetch_table_rows( + conn, + "knowledge_graph_type_definitions", + where_sql="knowledge_graph_id = %s", + params=(kg_id,), + ), + "extraction_runs": fetch_table_rows( + conn, "extraction_runs", where_sql="knowledge_graph_id = %s", params=(kg_id,) + ), + "extraction_jobs": fetch_table_rows( + conn, "extraction_jobs", where_sql="knowledge_graph_id = %s", params=(kg_id,) + ), + "extraction_agent_sessions": fetch_table_rows( + conn, + "extraction_agent_sessions", + where_sql="knowledge_graph_id = %s", + params=(kg_id,), + ), + "encrypted_credentials": credentials, + } + + +def fetch_graph_payload( + conn: psycopg2.extensions.connection, graph_name: str, kg_id: str +) -> dict[str, Any]: + escaped_kg_id = cypher_escape(kg_id) + escaped_graph_name = cypher_escape(graph_name) + with conn.cursor() as cur: + cur.execute("LOAD 'age'") + cur.execute('SET search_path = ag_catalog, "$user", public') + + cur.execute( + f""" + SELECT * FROM cypher('{escaped_graph_name}', $$ + MATCH (n) + WHERE n.knowledge_graph_id = '{escaped_kg_id}' + RETURN id(n), label(n), properties(n) + $$) AS (age_id agtype, label agtype, properties agtype) + """ + ) + nodes = [] + node_age_ids: set[str] = set() + for age_id, label, properties in cur.fetchall(): + age_id_text = str(agtype_to_python(age_id)) + label_text = str(agtype_to_python(label)) + props = agtype_to_python(properties) + if not isinstance(props, dict): + props = {} + nodes.append( + { + "age_id": age_id_text, + "label": label_text, + "properties": props, + } + ) + node_age_ids.add(age_id_text) + + cur.execute( + f""" + SELECT * FROM cypher('{escaped_graph_name}', $$ + MATCH (a)-[r]->(b) + WHERE a.knowledge_graph_id = '{escaped_kg_id}' + AND b.knowledge_graph_id = '{escaped_kg_id}' + RETURN id(r), label(r), id(a), id(b), properties(r) + $$) AS (age_id agtype, label agtype, start_age_id agtype, end_age_id agtype, properties agtype) + """ + ) + edges = [] + for age_id, label, start_age_id, end_age_id, properties in cur.fetchall(): + props = agtype_to_python(properties) + if not isinstance(props, dict): + props = {} + edges.append( + { + "age_id": str(agtype_to_python(age_id)), + "label": str(agtype_to_python(label)), + "start_age_id": str(agtype_to_python(start_age_id)), + "end_age_id": str(agtype_to_python(end_age_id)), + "properties": props, + } + ) + + return { + "graph_name": graph_name, + "knowledge_graph_id": kg_id, + "nodes": nodes, + "edges": edges, + } + + +def fetch_spicedb_payload( + spicedb_conn: psycopg2.extensions.connection, + kg_id: str, + data_source_ids: list[str], +) -> list[dict[str, str]]: + with spicedb_conn.cursor(cursor_factory=RealDictCursor) as cur: + if data_source_ids: + placeholders = sql.SQL(", ").join(sql.Placeholder() * len(data_source_ids)) + query = sql.SQL( + """ + SELECT namespace, object_id, relation, userset_namespace, userset_object_id, userset_relation + FROM relation_tuple + WHERE deleted_xid = '9223372036854775807'::xid8 + AND ( + (namespace = 'knowledge_graph' AND object_id = %s) + OR (namespace = 'data_source' AND object_id IN ({})) + ) + ORDER BY namespace, object_id, relation + """ + ).format(placeholders) + cur.execute(query, (kg_id, *data_source_ids)) + else: + cur.execute( + """ + SELECT namespace, object_id, relation, userset_namespace, userset_object_id, userset_relation + FROM relation_tuple + WHERE deleted_xid = '9223372036854775807'::xid8 + AND namespace = 'knowledge_graph' + AND object_id = %s + ORDER BY namespace, object_id, relation + """, + (kg_id,), + ) + return [dict(row) for row in cur.fetchall()] + + +def backup_dir_for(kg_id: str, backup_id: str) -> Path: + return BACKUP_ROOT / kg_id / backup_id + + +def resolve_backup_dir(kg_id: str, requested: str) -> Path: + kg_root = BACKUP_ROOT / kg_id + if requested == "latest": + latest = kg_root / "latest" + if latest.is_symlink(): + return latest.resolve() + if latest.is_dir(): + return latest + candidates = sorted(p for p in kg_root.iterdir() if p.is_dir() and p.name != "latest") + if not candidates: + raise SystemExit(f"No backups found for knowledge graph {kg_id}") + return candidates[-1] + + explicit = kg_root / requested + if explicit.is_dir(): + return explicit + if Path(requested).is_dir(): + return Path(requested) + raise SystemExit(f"Backup not found: {requested}") + + +def write_manifest( + backup_dir: Path, + *, + kg_id: str, + compose_project: str, + kg_name: str, + tenant_id: str, + workspace_id: str, + counts: dict[str, int], +) -> None: + manifest = { + "created_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "compose_project": compose_project, + "git_commit": git_commit_short(), + "knowledge_graph_id": kg_id, + "knowledge_graph_name": kg_name, + "tenant_id": tenant_id, + "workspace_id": workspace_id, + "age_graph_name": f"tenant_{tenant_id}", + "counts": counts, + } + write_json(backup_dir / "manifest.json", manifest) + + +def cmd_capture(args: argparse.Namespace) -> None: + validate_kg_id(args.knowledge_graph_id) + kg_id = args.knowledge_graph_id + + conn = connect() + try: + kg = fetch_kg_metadata(conn, kg_id) + tenant_id = kg["tenant_id"] + workspace_id = kg["workspace_id"] + graph_name = f"tenant_{tenant_id}" + + postgres_payload = fetch_postgres_payload(conn, kg_id, tenant_id) + graph_payload = fetch_graph_payload(conn, graph_name, kg_id) + finally: + conn.close() + + spicedb_conn = connect(spicedb_database_name()) + try: + spicedb_payload = fetch_spicedb_payload( + spicedb_conn, + kg_id, + [row["id"] for row in postgres_payload["data_sources"]], + ) + finally: + spicedb_conn.close() + + backup_id = timestamp_utc() + backup_dir = backup_dir_for(kg_id, backup_id) + backup_dir.mkdir(parents=True, exist_ok=True) + + write_json(backup_dir / "postgres.json", postgres_payload) + write_json(backup_dir / "graph.json", graph_payload) + write_json(backup_dir / "spicedb.json", spicedb_payload) + + counts = { + "postgres_knowledge_graphs": len(postgres_payload["knowledge_graphs"]), + "postgres_data_sources": len(postgres_payload["data_sources"]), + "postgres_data_source_sync_runs": len(postgres_payload["data_source_sync_runs"]), + "postgres_type_definitions": len(postgres_payload["knowledge_graph_type_definitions"]), + "postgres_extraction_runs": len(postgres_payload["extraction_runs"]), + "postgres_extraction_jobs": len(postgres_payload["extraction_jobs"]), + "postgres_extraction_agent_sessions": len(postgres_payload["extraction_agent_sessions"]), + "postgres_encrypted_credentials": len(postgres_payload["encrypted_credentials"]), + "graph_nodes": len(graph_payload["nodes"]), + "graph_edges": len(graph_payload["edges"]), + "spicedb_relationships": len(spicedb_payload), + } + write_manifest( + backup_dir, + kg_id=kg_id, + compose_project=args.compose_project, + kg_name=kg["name"], + tenant_id=tenant_id, + workspace_id=workspace_id, + counts=counts, + ) + + latest = BACKUP_ROOT / kg_id / "latest" + latest.parent.mkdir(parents=True, exist_ok=True) + if latest.exists() or latest.is_symlink(): + latest.unlink() + latest.symlink_to(backup_id, target_is_directory=True) + + print(f"Captured knowledge graph '{kg['name']}' ({kg_id})") + print(f"Backup directory: {backup_dir}") + print("Counts:") + for key, value in counts.items(): + print(f" {key}: {value}") + + +def decode_row_values(row: dict[str, Any]) -> dict[str, Any]: + decoded = dict(row) + if "encrypted_value" in decoded and isinstance(decoded["encrypted_value"], str): + decoded["encrypted_value"] = base64.b64decode(decoded["encrypted_value"]) + return decoded + + +def insert_rows( + conn: psycopg2.extensions.connection, + table: str, + rows: list[dict[str, Any]], +) -> None: + if not rows: + return + columns = list(rows[0].keys()) + placeholders = sql.SQL(", ").join(sql.Placeholder() * len(columns)) + statement = sql.SQL("INSERT INTO {} ({}) VALUES ({}) ON CONFLICT DO NOTHING").format( + sql.Identifier(table), + sql.SQL(", ").join(map(sql.Identifier, columns)), + placeholders, + ) + with conn.cursor() as cur: + for row in rows: + values = [decode_row_values(row)[column] for column in columns] + cur.execute(statement, values) + + +def delete_existing_kg_data(conn: psycopg2.extensions.connection, kg_id: str, graph_name: str) -> None: + escaped_kg_id = cypher_escape(kg_id) + escaped_graph_name = cypher_escape(graph_name) + with conn.cursor() as cur: + cur.execute("LOAD 'age'") + cur.execute('SET search_path = ag_catalog, "$user", public') + cur.execute( + f""" + SELECT * FROM cypher('{escaped_graph_name}', $$ + MATCH (n) + WHERE n.knowledge_graph_id = '{escaped_kg_id}' + DETACH DELETE n + $$) AS (result agtype) + """ + ) + + cur.execute("DELETE FROM extraction_agent_sessions WHERE knowledge_graph_id = %s", (kg_id,)) + cur.execute("DELETE FROM extraction_jobs WHERE knowledge_graph_id = %s", (kg_id,)) + cur.execute("DELETE FROM extraction_runs WHERE knowledge_graph_id = %s", (kg_id,)) + cur.execute( + "DELETE FROM knowledge_graph_type_definitions WHERE knowledge_graph_id = %s", + (kg_id,), + ) + cur.execute( + """ + DELETE FROM data_source_sync_runs + WHERE data_source_id IN (SELECT id FROM data_sources WHERE knowledge_graph_id = %s) + """, + (kg_id,), + ) + cur.execute( + """ + DELETE FROM encrypted_credentials + WHERE path IN ( + SELECT credentials_path FROM data_sources + WHERE knowledge_graph_id = %s AND credentials_path IS NOT NULL + ) + """, + (kg_id,), + ) + cur.execute("DELETE FROM data_sources WHERE knowledge_graph_id = %s", (kg_id,)) + cur.execute("DELETE FROM knowledge_graphs WHERE id = %s", (kg_id,)) + + +def ensure_label_exists(cur: Any, graph_name: str, label: str, *, edge: bool) -> None: + cur.execute( + """ + SELECT l.id + FROM ag_catalog.ag_label l + JOIN ag_catalog.ag_graph g ON l.graph = g.graphid + WHERE g.name = %s AND l.name = %s + """, + (graph_name, label), + ) + if cur.fetchone(): + return + if edge: + cur.execute("SELECT ag_catalog.create_elabel(%s, %s)", (graph_name, label)) + else: + cur.execute("SELECT ag_catalog.create_vlabel(%s, %s)", (graph_name, label)) + + +def cypher_escape(value: str) -> str: + return value.replace("\\", "\\\\").replace("'", "\\'") + + +def cypher_literal(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, list): + return "[" + ", ".join(cypher_literal(item) for item in value) + "]" + if isinstance(value, dict): + inner = ", ".join(f"{key}: {cypher_literal(item)}" for key, item in value.items()) + return "{" + inner + "}" + text = cypher_escape(str(value)) + return f"'{text}'" + + +def restore_graph_nodes( + conn: psycopg2.extensions.connection, graph_payload: dict[str, Any] +) -> None: + graph_name = graph_payload["graph_name"] + with conn.cursor() as cur: + cur.execute("LOAD 'age'") + cur.execute('SET search_path = ag_catalog, "$user", public') + + labels = sorted({node["label"] for node in graph_payload["nodes"]}) + for label in labels: + ensure_label_exists(cur, graph_name, label, edge=False) + + for node in graph_payload["nodes"]: + label = node["label"] + props_map = ", ".join( + f"{key}: {cypher_literal(value)}" for key, value in node["properties"].items() + ) + query = f""" + SELECT * FROM cypher('{cypher_escape(graph_name)}', $$ + CREATE (n:{label} {{{props_map}}}) + RETURN id(n) + $$) AS (age_id agtype) + """ + cur.execute(query) + + edge_labels = sorted({edge["label"] for edge in graph_payload["edges"]}) + for label in edge_labels: + ensure_label_exists(cur, graph_name, label, edge=True) + + for edge in graph_payload["edges"]: + label = edge["label"] + props_map = ", ".join( + f"{key}: {cypher_literal(value)}" for key, value in edge["properties"].items() + ) + start_id = cypher_escape(edge["start_age_id"]) + end_id = cypher_escape(edge["end_age_id"]) + if props_map: + create_edge = f""" + MATCH (a), (b) + WHERE id(a) = {start_id} AND id(b) = {end_id} + CREATE (a)-[r:{label} {{{props_map}}}]->(b) + RETURN id(r) + """ + else: + create_edge = f""" + MATCH (a), (b) + WHERE id(a) = {start_id} AND id(b) = {end_id} + CREATE (a)-[r:{label}]->(b) + RETURN id(r) + """ + query = f""" + SELECT * FROM cypher('{cypher_escape(graph_name)}', $$ + {create_edge} + $$) AS (age_id agtype) + """ + cur.execute(query) + + +def spicedb_settings() -> tuple[str, str]: + env_values = load_env_file(SPICEDB_ENV) + token = os.getenv("SPICEDB_GRPC_PRESHARED_KEY", env_values.get("SPICEDB_GRPC_PRESHARED_KEY", "changeme")) + network = os.getenv("KARTOGRAPH_COMPOSE_NETWORK", "kartograph_kartograph") + return token, network + + +def restore_spicedb_relationships(relationships: list[dict[str, str]]) -> None: + token, network = spicedb_settings() + certs_dir = REPO_ROOT / "certs" + for rel in relationships: + resource = f"{rel['namespace']}:{rel['object_id']}" + subject = f"{rel['userset_namespace']}:{rel['userset_object_id']}" + if rel["userset_relation"] and rel["userset_relation"] != "...": + subject = f"{subject}#{rel['userset_relation']}" + cmd = [ + "docker", + "run", + "--rm", + "--network", + network, + "-e", + "ZED_ENDPOINT=spicedb:50051", + "-e", + f"ZED_TOKEN={token}", + "-e", + "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/certs/spicedb-cert.pem", + "-v", + f"{certs_dir}:/certs:ro", + "authzed/zed:latest", + "--no-verify-ca", + "relationship", + "touch", + resource, + rel["relation"], + subject, + ] + subprocess.run(cmd, check=True, capture_output=True, text=True) + + +def cmd_restore(args: argparse.Namespace) -> None: + validate_kg_id(args.knowledge_graph_id) + backup_dir = resolve_backup_dir(args.knowledge_graph_id, args.backup_id) + manifest = read_json(backup_dir / "manifest.json") + postgres_payload = read_json(backup_dir / "postgres.json") + graph_payload = read_json(backup_dir / "graph.json") + spicedb_payload = read_json(backup_dir / "spicedb.json") + + if not args.yes: + print(f"This will restore knowledge graph {args.knowledge_graph_id} from:") + print(f" {backup_dir}") + if args.replace: + print("Existing data for this knowledge graph will be deleted first.") + reply = input("Continue? [y/N] ").strip().lower() + if reply not in {"y", "yes"}: + raise SystemExit("Aborted.") + + conn = connect() + try: + conn.autocommit = False + if args.replace: + delete_existing_kg_data(conn, args.knowledge_graph_id, graph_payload["graph_name"]) + + for table in POSTGRES_TABLES_IN_RESTORE_ORDER: + insert_rows(conn, table, postgres_payload.get(table, [])) + + restore_graph_nodes(conn, graph_payload) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + restore_spicedb_relationships(spicedb_payload) + + print(f"Restored knowledge graph '{manifest.get('knowledge_graph_name', args.knowledge_graph_id)}'") + print(f"From backup: {backup_dir}") + + +def cmd_list(args: argparse.Namespace) -> None: + validate_kg_id(args.knowledge_graph_id) + kg_root = BACKUP_ROOT / args.knowledge_graph_id + if not kg_root.exists(): + print(f"No backups yet for knowledge graph {args.knowledge_graph_id}") + return + print(f"Backups for knowledge graph {args.knowledge_graph_id} in {kg_root}:") + for path in sorted(p for p in kg_root.iterdir() if p.is_dir() and p.name != "latest"): + manifest_path = path / "manifest.json" + if manifest_path.exists(): + manifest = read_json(manifest_path) + counts = manifest.get("counts", {}) + print( + f" {path.name} nodes={counts.get('graph_nodes', '?')} " + f"data_sources={counts.get('postgres_data_sources', '?')} " + f"name={manifest.get('knowledge_graph_name', '?')}" + ) + else: + print(f" {path.name}") + latest = kg_root / "latest" + if latest.is_symlink(): + print(f"\nlatest -> {os.readlink(latest)}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + capture = subparsers.add_parser("capture", help="Capture a knowledge graph backup") + capture.add_argument("knowledge_graph_id") + capture.add_argument( + "--compose-project", + default=os.getenv("COMPOSE_PROJECT", "kartograph"), + help="Docker compose project name (stored in manifest metadata)", + ) + capture.set_defaults(func=cmd_capture) + + restore = subparsers.add_parser("restore", help="Restore a knowledge graph backup") + restore.add_argument("knowledge_graph_id") + restore.add_argument("backup_id", nargs="?", default="latest") + restore.add_argument( + "--replace", + action="store_true", + help="Delete existing data for this knowledge graph before restoring", + ) + restore.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt") + restore.set_defaults(func=cmd_restore) + + list_cmd = subparsers.add_parser("list", help="List backups for a knowledge graph") + list_cmd.add_argument("knowledge_graph_id") + list_cmd.set_defaults(func=cmd_list) + + return parser + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/specs/extraction/agent-sessions.spec.md b/specs/extraction/agent-sessions.spec.md new file mode 100644 index 000000000..4f2e44a7b --- /dev/null +++ b/specs/extraction/agent-sessions.spec.md @@ -0,0 +1,61 @@ +# Agent Sessions + +## Purpose +Agent sessions provide long-running conversational extraction workflows scoped to user, knowledge graph, and mode. Sessions remain active until explicitly cleared, while preserving auditable run history and metrics. + +## Requirements + +### Requirement: Session Scope +The system SHALL scope extraction agent sessions per user, knowledge graph, and mode. + +#### Scenario: Scope isolation +- GIVEN two users working on the same knowledge graph +- WHEN they open extraction agent sessions +- THEN each user receives a separate session +- AND session state is not shared across users + +#### Scenario: Mode isolation +- GIVEN a user session in bootstrap mode and a session in extraction mode +- WHEN both sessions exist for the same knowledge graph +- THEN each session keeps separate context and runtime state + +### Requirement: Long-Running Session Lifecycle +The system SHALL keep sessions active until explicit reset. + +#### Scenario: Persistent session context +- GIVEN an active extraction agent session +- WHEN the user sends follow-up messages over time +- THEN prior session context remains available for continued conversation + +#### Scenario: Chat turn persistence +- GIVEN a completed graph-management chat turn +- WHEN the assistant reply is emitted +- THEN user and assistant messages are persisted on the session +- AND sticky runtime metadata is updated on the session runtime context + +### Requirement: Sticky Runtime Association +The system SHALL associate active sessions with sticky container runtime leases. + +#### Scenario: Runtime metadata on session +- GIVEN a chat turn starts a or reuses a sticky container +- WHEN the turn is accepted +- THEN session runtime context records sticky container identity and status + +### Requirement: Clear Chat Reset +The system SHALL provide an explicit "Clear chat" action that resets runtime context. + +#### Scenario: Full reset on clear +- GIVEN an active session with runtime context +- WHEN the user clicks "Clear chat" +- THEN message history and runtime context are reset +- AND a new clean session is started for that user/knowledge-graph/mode scope + +### Requirement: Session Archival and Retention +The system SHALL retain completed session and run records indefinitely. + +#### Scenario: Historical session visibility +- GIVEN prior sessions and mutation runs +- WHEN users or administrators query session history +- THEN archived sessions and associated run records remain available +- AND each record includes last-updated timestamps and run-level metrics + diff --git a/specs/extraction/chat-turns.spec.md b/specs/extraction/chat-turns.spec.md new file mode 100644 index 000000000..b4bd84458 --- /dev/null +++ b/specs/extraction/chat-turns.spec.md @@ -0,0 +1,114 @@ +# Chat Turns + +## Purpose +Graph Management chat turns orchestrate conversational extraction agent workloads inside sticky session containers. Each turn persists user and assistant messages, streams transparent activity to the UI, and gates execution until ingestion context (JobPackage) is available when required by the active graph-management mode. + +## Requirements + +### Requirement: Sticky Session Container Execution +The system SHALL execute graph-management chat turns in a sticky session container assigned to the active extraction agent session. + +#### Scenario: Reuse sticky runtime across turns +- GIVEN an active extraction agent session with a running sticky container +- WHEN the user sends a follow-up chat message +- THEN the same sticky container lease is reused until clear-chat, timeout, or reset + +#### Scenario: Start sticky runtime on first turn +- GIVEN an active session without a sticky container lease +- WHEN the user sends the first chat message +- THEN the system starts a sticky session container for that session scope +- AND records container identity in session runtime context + +### Requirement: JobPackage Context in Sticky Runtime +The system SHALL load ingestion context from JobPackage archives into the sticky session container when JobPackage access is required. + +#### Scenario: JobPackage required for extraction jobs mode +- GIVEN graph-management UI mode `Extraction Jobs` +- AND at least one data source exists for the knowledge graph +- WHEN JobPackage context is not yet prepared for all tracked sources +- THEN the chat turn enters a wait state instead of invoking the agent +- AND the UI receives wait-phase activity explaining that ingestion context is pending + +#### Scenario: JobPackage ready +- GIVEN graph-management UI mode `Extraction Jobs` +- AND prepared ingestion context exists for the knowledge graph +- WHEN the user sends a chat message +- THEN JobPackage material is available to the sticky container agent runtime +- AND the agent turn proceeds normally + +#### Scenario: Schema design without JobPackage gate +- GIVEN graph-management UI mode `Initial Schema Design` +- WHEN the user sends a chat message +- THEN JobPackage readiness is not required to start the agent turn +- AND schema-bootstrap skills remain primary framing + +### Requirement: Mode-Aware Skill Framing +The system SHALL resolve agent skills using workspace session mode and graph-management UI mode. + +#### Scenario: Three UI mode skill overlays +- GIVEN graph-management UI modes `Initial Schema Design`, `Extraction Jobs`, and `One-off Mutations` +- WHEN a chat turn starts +- THEN skill framing reflects the selected UI mode +- AND global templates plus knowledge-graph overrides still apply underneath + +### Requirement: Streaming Chat Turn Contract +The system SHALL expose chat turns over an NDJSON streaming HTTP endpoint. + +#### Scenario: Thinking transparency +- GIVEN an in-progress chat turn +- WHEN the agent performs preparatory work +- THEN the stream emits `thinking` events with recent activity lines for UI display + +#### Scenario: Wait transparency +- GIVEN JobPackage context is required but unavailable +- WHEN the user sends a chat message +- THEN the stream emits a `wait` event with phase `awaiting_job_package` +- AND completes with an assistant explanation of the wait condition + +#### Scenario: Successful completion +- GIVEN an agent turn completes successfully +- WHEN the stream finishes +- THEN a terminal `done` event includes the assistant reply +- AND user and assistant messages are persisted on the session + +### Requirement: Clear Chat Resets Runtime +The system SHALL reset sticky session runtime when clear-chat is invoked. + +#### Scenario: Clear chat terminates sticky container +- GIVEN an active session with sticky runtime state +- WHEN the user clicks Clear chat +- THEN the sticky container is reset +- AND a new clean session is started for the same scope + +### Requirement: Graph Management Assistant Tooling +The system SHALL expose schema, mutation, and workspace tooling appropriate for bootstrap prepopulation workflows. + +#### Scenario: Bash and workspace generators +- GIVEN an active graph-management chat turn in schema bootstrap mode +- WHEN the agent runtime starts +- THEN Bash is an allowed tool scoped to the session workspace +- AND `instance_generators/` contains `_entity_scanner.example.py`, `entities_to_jsonl.py`, and `relationships_to_jsonl.py` + +#### Scenario: Compact follow-up prompts +- GIVEN a graph-management session with prior user messages in the turn history +- WHEN a follow-up chat message is processed +- THEN the system prompt omits the full skill prose block +- AND still includes live workspace readiness and a short tools summary + +### Requirement: Multi-Deliverable Turn Pacing +The system SHALL instruct the Graph Management Assistant to pace multi-item bootstrap requests across turns. + +#### Scenario: One phase per turn by default +- GIVEN the user sends one message with multiple bootstrap deliverables +- WHEN the Graph Management Assistant processes the turn +- THEN schema bootstrap guardrails require completing at most one bootstrap phase +- AND the assistant asks whether to continue automatically or one phase at a time + +### Requirement: Sticky Turn Timeout +The system SHALL allow configuring a per-turn execution timeout for graph-management chat in sticky session containers. + +#### Scenario: One-hour dev timeout +- GIVEN development runtime settings set `STICKY_TURN_TIMEOUT_SECONDS` to 3600 +- WHEN a chat turn runs in the sticky agent runtime +- THEN the agent turn may execute for up to 3600 seconds before timing out +- AND the API sticky HTTP client read timeout exceeds the configured turn timeout diff --git a/specs/extraction/one-off-mutations.spec.md b/specs/extraction/one-off-mutations.spec.md new file mode 100644 index 000000000..f93fdda91 --- /dev/null +++ b/specs/extraction/one-off-mutations.spec.md @@ -0,0 +1,67 @@ +# One-off Mutations (Graph Management) + +## Purpose +One-off Mutations is a Graph Management Assistant UI mode for direct schema and instance edits. The operator describes a change; the assistant validates and applies it via mutation tools. Sessions archive to Graph Writes History with token cost and applied JSONL. + +## Requirements + +### Requirement: One-off Mutations Skill Pack +The system SHALL resolve a dedicated skill pack when graph-management UI mode is `one-off-mutations`. + +#### Scenario: Skills include edit workflows +- GIVEN UI mode `one-off-mutations` +- WHEN skills are resolved for a chat turn +- THEN instance and schema edit workflow skills are primary +- AND confirmation policy for destructive operations is included + +### Requirement: Assistant Executes Edits In Session +The system SHALL implement requested schema and instance changes via Kartograph schema tools without deferring to extraction job workers. + +#### Scenario: Instance property update +- GIVEN an operator asks to update a property on an existing instance +- WHEN the assistant completes the turn +- THEN it validates and applies UPDATE JSONL mutations +- AND reports write operation counts + +#### Scenario: Bulk instance cleanup +- GIVEN an operator asks to delete many instances and keep or create a specific set +- WHEN the assistant completes the turn +- THEN it lists instances by type (not per-slug search loops) +- AND generates JSONL in batch to a workspace file or script +- AND validates once and applies once via file-based mutation tools + +#### Scenario: Schema type change +- GIVEN an operator asks to add an optional property to an entity type +- WHEN the assistant completes the turn +- THEN it saves ontology via `kartograph_save_schema_ontology` after confirmation when required + +### Requirement: JobPackage Not Required +The system SHALL NOT block one-off mutations chat on JobPackage ingestion readiness. + +#### Scenario: Chat without prepared sources +- GIVEN no JobPackages are prepared +- WHEN the operator uses one-off mutations mode +- THEN the chat turn proceeds without awaiting ingestion + +### Requirement: GMA Session Archive +The system SHALL archive each Graph Management Assistant session to Graph Writes History when chat is cleared. + +#### Scenario: Archive with writes and cost +- GIVEN a GMA session applied mutations and consumed tokens +- WHEN the operator clears chat +- THEN one ARCHIVED entry is persisted +- AND job set name reflects the UI mode (Initial Schema Design, Extraction Jobs, or One-off Mutations) + +#### Scenario: Token-only session +- GIVEN a GMA session consumed tokens but applied no graph writes +- WHEN chat is cleared +- THEN an ARCHIVED entry is still persisted with cost metrics + +### Requirement: Graph Writes History Presentation +The system SHALL present archived GMA sessions and extraction worker jobs in a unified Graph Writes History view. + +#### Scenario: Job list shows cost +- GIVEN an archived job or GMA session with cost metadata +- WHEN the operator views Graph Writes History +- THEN each entry shows write count and total cost in USD +- AND GMA sessions are distinguishable from extraction worker jobs diff --git a/specs/extraction/operations.spec.md b/specs/extraction/operations.spec.md new file mode 100644 index 000000000..bf05ff156 --- /dev/null +++ b/specs/extraction/operations.spec.md @@ -0,0 +1,103 @@ +# Operations + +## Purpose +Extraction operations define the mode-specific behaviors for schema bootstrap, extraction job setup, and minor direct edits. All write behavior is expressed as MutationLogs associated with a knowledge graph and session. + +## Requirements + +### Requirement: Mode-Specific Skill Sets +The system SHALL provide different default skill sets for bootstrap and extraction operations modes. + +#### Scenario: Bootstrap skills +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN an extraction agent session starts +- THEN the default skill set is schema-bootstrap oriented +- AND it prioritizes complete entity/relationship modeling and prepopulated instance coverage + +#### Scenario: Extraction skills +- GIVEN a knowledge graph in `extraction_operations` +- WHEN an extraction agent session starts +- THEN the default skill set is extraction-job-setup and minor-direct-edit oriented +- AND schema edit skills remain available but are not the primary framing + +### Requirement: Graph Management UI Mode Overlays +The system SHALL apply graph-management UI mode overlays on top of workspace session mode skills. + +#### Scenario: Initial schema design overlay +- GIVEN graph-management UI mode `Initial Schema Design` +- WHEN a chat turn resolves skills +- THEN schema bootstrap and validation guidance is primary + +#### Scenario: Extraction jobs overlay +- GIVEN graph-management UI mode `Extraction Jobs` +- WHEN a chat turn resolves skills +- THEN extraction job setup and sync-run guidance is primary +- AND JobPackage readiness is required before agent execution + +#### Scenario: One-off mutations overlay +- GIVEN graph-management UI mode `One-off Mutations` +- WHEN a chat turn resolves skills +- THEN scoped JSONL mutation authoring and schema edit guidance is primary +- AND JobPackage readiness is not required + +### Requirement: Graph Writes History +The system SHALL archive Graph Management Assistant sessions and extraction worker jobs that apply graph writes or incur assistant token cost. + +#### Scenario: GMA session job set names +- GIVEN a GMA session archived from graph management +- WHEN the job set name is recorded +- THEN it is one of: `Graph Management · Initial Schema Design`, `Graph Management · Extraction Jobs`, or `Graph Management · One-off Mutations` + +#### Scenario: Unified history view +- GIVEN archived GMA sessions and extraction jobs exist +- WHEN the operator opens Graph Writes History in the project workspace +- THEN both entry types appear grouped by run and job set +- AND each entry shows write count and total cost + +### Requirement: Skill Resolution Model +The system SHALL resolve agent skills using global templates with knowledge-graph overrides. + +#### Scenario: Global template with override +- GIVEN a knowledge graph with custom skill overrides +- WHEN an extraction session resolves skill instructions +- THEN global skill templates are loaded first +- AND knowledge-graph overrides are applied on top + +### Requirement: Unified Extraction and Manual Edit Surface +The system SHALL provide one operational area for extraction jobs and minor direct graph edits. + +#### Scenario: Unified write path +- GIVEN a user in extraction operations mode +- WHEN the user runs extraction jobs or performs minor direct edits +- THEN both behaviors emit MutationLogs +- AND both target the same knowledge graph + +### Requirement: Validate-Then-Transition Workflow +The system SHALL gate transition from bootstrap mode through explicit validation and user action. + +#### Scenario: Validation gate +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN the user clicks Validate +- THEN validation results are returned and persisted +- AND transition remains unavailable until checks pass + +#### Scenario: Explicit transition action +- GIVEN validation has passed in `schema_bootstrap` +- WHEN the user clicks "Go to Extraction/Mutations" +- THEN the knowledge graph transitions to `extraction_operations` +- AND a new extraction-mode agent session is started + +### Requirement: MutationLog Session Association +The system SHALL associate MutationLogs with both knowledge graph and session/run identity. + +#### Scenario: Session-linked mutation runs +- GIVEN a session producing mutation operations +- WHEN MutationLogs are persisted +- THEN each log run stores session ID, knowledge graph ID, actor identity, and timestamps + +#### Scenario: Per-run operation metrics +- GIVEN a persisted mutation log run +- WHEN metrics are recorded +- THEN operation counts are captured by operation class (for example create/update for entity and relationship instances) +- AND token usage and cost metrics are captured for the run + diff --git a/specs/extraction/sticky-session-runtime.spec.md b/specs/extraction/sticky-session-runtime.spec.md new file mode 100644 index 000000000..b287c1948 --- /dev/null +++ b/specs/extraction/sticky-session-runtime.spec.md @@ -0,0 +1,50 @@ +# Sticky Session Runtime + +## Purpose +Sticky session runtimes host long-lived Claude Agent SDK workloads for Graph Management chat. Each active extraction agent session receives an isolated container with mounted skills, scoped credentials, and optional JobPackage materialization for repository access. + +## Requirements + +### Requirement: Isolated Sticky Container per Session +The system SHALL run each active graph-management chat session in an isolated container. + +#### Scenario: Session-scoped isolation +- GIVEN two users with active sessions on the same knowledge graph +- WHEN both send chat messages +- THEN each session uses a distinct sticky container lease +- AND container labels include session, user, knowledge graph, and mode identifiers + +### Requirement: Claude Agent SDK Runtime +The system SHALL host Claude Agent SDK agent instances inside sticky session containers. + +#### Scenario: Agent runtime image +- GIVEN a sticky session container starts +- WHEN the container initializes +- THEN it runs an agent runtime process capable of Claude Agent SDK execution +- AND is distinct from ephemeral JobPackage worker containers used for sync extraction + +### Requirement: Skills Directory Mount +The system SHALL mount the platform skills directory into sticky session containers. + +#### Scenario: Skills available at runtime +- GIVEN a sticky session container starts +- WHEN the agent runtime initializes +- THEN SKILL.md resources from the platform skills directory are readable inside the container + +### Requirement: JobPackage Materialization +The system SHALL materialize JobPackage archives into sticky session containers when ingestion context is required. + +#### Scenario: Repository files available +- GIVEN JobPackage context is ready for the knowledge graph +- WHEN a sticky container starts or refreshes ingestion context +- THEN manifest, changeset, content, and reconstructed repository files are available under the session work directory +- AND the agent can inspect data-source content without leaving the container + +### Requirement: Scoped Runtime Credentials +The system SHALL inject short-lived credentials into sticky session containers using least-privilege tenant and knowledge-graph scope. + +#### Scenario: Credential injection at start +- GIVEN a sticky session container is started +- WHEN runtime credentials are issued +- THEN credentials are injected as environment variables or runtime files +- AND credentials are never persisted in mutation logs or session message history diff --git a/specs/graph/bidirectional-relationships.spec.md b/specs/graph/bidirectional-relationships.spec.md new file mode 100644 index 000000000..4e63881d9 --- /dev/null +++ b/specs/graph/bidirectional-relationships.spec.md @@ -0,0 +1,158 @@ +# Bidirectional Relationships + +## Purpose +Relationship types in Kartograph are directed edges. Many bootstrap and query use cases need traversal from either endpoint without debating arrow direction at authoring time. This spec defines **paired relationship types** (primary + inverse) and **twin edge instances** so that every bidirectional relationship is materialized as two explicit graph edges with distinct labels, validated for parity, and visible in schema design artifacts. + +This complements schema authoring: the Graph Management Assistant authors the primary direction; the platform materializes the inverse type and twin instances by default. + +## Design principles + +- **Explicit over implicit:** Twin edges are separate mutation log lines and separate AGE edges — auditable and idempotent. +- **Opt-out, not opt-in:** New relationship types default to bidirectional pairing. Causal or asymmetric relationships (e.g. `depends_on`, `created_by`) set `bidirectional: false`. +- **Distinct inverse labels:** Primary and inverse use different edge labels (e.g. `contains` / `contained_in`), not the same label reversed. Semantics and UI already assume this (`reverse_relationship_type` in design artifacts). +- **No hyperedge shortcut:** Pairing is always between two node types with one primary direction declared in ontology. + +## Requirements + +### Requirement: Bidirectional pairing metadata on relationship types +The system SHALL store bidirectional pairing metadata on canonical relationship type definitions. + +#### Scenario: Default bidirectional on new relationship type +- GIVEN a new relationship type `Repository → contains → Test` is added to the ontology +- AND `bidirectional` is omitted +- WHEN the ontology is saved +- THEN the relationship type is stored with `bidirectional=true` +- AND an inverse relationship type is created or linked: `Test → contained_in → Repository` (inverse label derived or explicit) +- AND design artifacts expose `reverse_relationship_type` and `reverse_relationship_description` for the primary row + +#### Scenario: Opt out of bidirectional pairing +- GIVEN a relationship type `Service → depends_on → Service` with `bidirectional=false` +- WHEN the ontology is saved +- THEN no inverse relationship type is auto-generated +- AND instance twin validation does not apply to that label + +#### Scenario: Explicit inverse label +- GIVEN a primary relationship type with `bidirectional=true` and `inverse_label="housed_in"` +- WHEN the ontology is saved +- THEN the inverse type uses label `housed_in` with swapped `source_labels` and `target_labels` +- AND metadata links `inverse_of` on the inverse type back to the primary label + +### Requirement: Inverse type materialization on ontology save +The system SHALL ensure every bidirectional primary relationship type has a corresponding inverse type definition before instances are created. + +#### Scenario: Auto-generate missing inverse type +- GIVEN ontology save includes `repository|contains|test` as a bidirectional primary +- AND no inverse type exists yet +- WHEN save completes +- THEN canonical schema includes `test|contained_in|repository` (or explicit `inverse_label`) +- AND both types share pairing metadata (`bidirectional_pair_key`) + +#### Scenario: Reject invalid inverse pairing +- GIVEN a relationship type references `inverse_label` that already exists with incompatible endpoints +- WHEN ontology save is attempted +- THEN validation fails with a clear error + +### Requirement: Twin edge instances on CREATE +The system SHALL create paired edge instances for bidirectional relationship types when a primary edge instance is created. + +#### Scenario: Primary CREATE expands to twin CREATE +- GIVEN bidirectional relationship `contains` from Repository node R to Test node T +- WHEN a CREATE edge mutation is applied for `R -[contains]-> T` +- THEN the mutation batch also CREATEs `T -[contained_in]-> R` in the same atomic apply +- AND both edges receive distinct deterministic ids +- AND inverse edge properties copy non-directional fields from the primary; directional fields may be omitted on the inverse + +#### Scenario: Bulk JSONL primary-only input +- GIVEN a JSONL file with only primary-direction edge CREATE lines for bidirectional types +- WHEN mutations are validated or applied via workload tools +- THEN the preflight/expansion layer adds inverse CREATE lines before apply +- AND validate reports the expanded operation count + +#### Scenario: Idempotent re-apply +- GIVEN twin edges for pair (R, T) already exist +- WHEN the same primary CREATE is submitted again under strict CREATE semantics +- THEN validation rejects the duplicate primary CREATE +- AND no orphan inverse edge is created + +### Requirement: Twin instance validation +The system SHALL validate that bidirectional relationship instances exist in pairs. + +#### Scenario: Readiness reports missing inverse instance +- GIVEN a bidirectional primary edge instance exists without its inverse twin +- WHEN workspace readiness or design artifacts are evaluated +- THEN a blocking or warning reason identifies the orphan primary edge (source slug, target slug, label) +- AND transition eligibility may be blocked when strict pairing mode is enabled for bootstrap + +#### Scenario: Balanced pairing passes validation +- GIVEN every primary `contains` edge has a matching `contained_in` edge between the same node ids (reversed) +- WHEN twin validation runs +- THEN no pairing defects are reported + +### Requirement: Authoring guidance +The system SHALL instruct the Graph Management Assistant to author primary direction only for bidirectional types. + +#### Scenario: GMA authors one direction +- GIVEN schema bootstrap with bidirectional relationship types +- WHEN the assistant plans prepopulation +- THEN it emits generator output for the primary label only +- AND relies on platform twin expansion for inverse instances +- AND does not ask the user to confirm arrow direction when `bidirectional=true` unless `bidirectional=false` is set + +## Data model (canonical type metadata) + +Primary relationship type (`edge`, entity_type=edge): + +| Field | Default | Meaning | +|-------|---------|---------| +| `bidirectional` | `true` | Whether twin inverse type + instances are required | +| `inverse_label` | derived | Label of inverse edge type; default `{primary}_inverse` or linguistic map | +| `bidirectional_pair_key` | derived | Stable key `source\|primary\|target` linking primary and inverse rows | + +Inverse relationship type (auto-generated): + +| Field | Meaning | +|-------|---------| +| `inverse_of` | Primary label this type mirrors | +| `bidirectional` | `true` | +| `auto_generated` | `true` — hide from GMA authoring prompts or show as read-only twin | + +## Inverse label derivation (default) + +When `inverse_label` is not provided and `bidirectional=true`: + +1. Use a small built-in map for common verbs (`contains` → `contained_in`, `defines` → `defined_by`, `implements` → `implemented_by`). +2. Otherwise default to `{primary_label}_inverse` (snake_case). + +Authors MAY override `inverse_label` in ontology JSON. + +## Write path summary + +``` +Ontology save (Management → canonical schema) + → pairing expander adds/updates inverse type definitions + +Edge CREATE (Graph / Extraction workload) + → twin expander adds inverse CREATE to batch + → mutation applier executes both in one transaction + +Readiness (Management / Extraction) + → twin validator checks primary/inverse instance parity +``` + +## Read path summary + +- **Design artifacts:** populate `reverse_relationship_type` from pairing metadata (UI already renders it). +- **Relationship listing:** workload list tools may group primary + inverse counts or report twin balance. +- **Queries:** agents traverse using the label appropriate to start node type; both directions always exist when bidirectional. + +## Out of scope (initial tracer) + +- Automatic linguistic inference beyond the small verb map. +- Symmetric edges with the **same** label in both directions (conflicts with distinct-semantics principle). +- Retroactive twin backfill job for graphs authored before this feature (separate migration spec). +- Graph query MCP auto-expanding undirected traversals (clients use explicit labels). + +## Migration notes + +- Existing ontologies without pairing metadata: treat as `bidirectional=false` until re-saved or migrated. +- Existing orphan edge instances: report in readiness; optional backfill command in a follow-up. diff --git a/specs/graph/mutations.spec.md b/specs/graph/mutations.spec.md index 50a90da5c..cd3937437 100644 --- a/specs/graph/mutations.spec.md +++ b/specs/graph/mutations.spec.md @@ -55,12 +55,12 @@ The system SHALL support declaring node and edge types with property schemas. - GIVEN a DEFINE operation with label "person", description, and required properties - WHEN the mutation is applied - THEN a type definition is stored with the label, description, required properties, and empty optional properties -- AND system properties (`data_source_id`, `source_path`, `slug`) are automatically added to required properties +- AND system properties (`data_source_id`, `slug`) are automatically added to required properties #### Scenario: Define an edge type - GIVEN a DEFINE operation with entity type "edge" - WHEN the mutation is applied -- THEN system properties for edges (`data_source_id`, `source_path`) are automatically added +- THEN system properties for edges (`data_source_id`) are automatically added ### Requirement: CREATE Operation The system SHALL support idempotent entity creation with property accumulation. @@ -133,11 +133,13 @@ The system SHALL require specific system-managed properties on all CREATE operat #### Scenario: Node system properties - GIVEN a CREATE operation for a node -- THEN `data_source_id`, `source_path`, `slug`, and `knowledge_graph_id` MUST be present in `set_properties` +- THEN `data_source_id`, `slug`, and `knowledge_graph_id` MUST be present in `set_properties` +- AND `source_path` MAY be present when the caller or type definition requires provenance #### Scenario: Edge system properties - GIVEN a CREATE operation for an edge -- THEN `data_source_id`, `source_path`, and `knowledge_graph_id` MUST be present in `set_properties` +- THEN `data_source_id` and `knowledge_graph_id` MUST be present in `set_properties` +- AND `source_path` MAY be present when the caller or type definition requires provenance ### Requirement: Deterministic Entity IDs The system SHALL use deterministic IDs for idempotent mutation replay. @@ -157,3 +159,18 @@ The system SHALL enforce correct ordering of operations to maintain referential - AND DELETE operations run next (edges before nodes) - AND CREATE operations follow (nodes before edges) - AND UPDATE operations run last + +### Requirement: MutationLog Run Metadata +The system SHALL persist run-level metadata for mutation logs. + +#### Scenario: Session and scope association +- GIVEN mutations produced by an extraction or manual-edit session +- WHEN the mutation log run is persisted +- THEN the run is associated with session ID and knowledge graph ID +- AND actor identity and run timestamps are recorded + +#### Scenario: Metrics capture +- GIVEN a persisted mutation log run +- WHEN run metrics are finalized +- THEN token usage and cost totals are stored +- AND operation counts are stored by operation class diff --git a/specs/graph/schema-authoring.spec.md b/specs/graph/schema-authoring.spec.md new file mode 100644 index 000000000..897439c7f --- /dev/null +++ b/specs/graph/schema-authoring.spec.md @@ -0,0 +1,149 @@ +# Schema Authoring + +## Purpose +Schema authoring defines how entity and relationship type definitions are created and evolved in the graph through mutation logs. It supports a bootstrap flow for first-time schema establishment and ongoing schema evolution during extraction operations. + +## Requirements + +### Requirement: Graph-Native Type Definitions +The system SHALL treat graph-stored type definitions as the canonical schema source. + +#### Scenario: Canonical storage +- GIVEN schema mutations are applied +- WHEN entity and relationship type definitions are persisted +- THEN canonical schema state is stored in the graph schema layer +- AND no parallel "design artifact" source of truth is required + +### Requirement: Bootstrap Authoring Flow +The system SHALL support schema authoring during `schema_bootstrap` mode through mutation logs. + +#### Scenario: Bootstrap schema creation +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN an agent or user creates entity and relationship types +- THEN changes are written via mutation logs +- AND resulting graph schema reflects those mutations + +#### Scenario: Capabilities-driven start +- GIVEN a new bootstrap session +- WHEN the schema agent starts +- THEN it asks for user capabilities/goals +- AND it offers two paths: an immediate first-pass schema attempt, or guided question-by-question co-design + +### Requirement: Ongoing Schema Evolution +The system SHALL allow schema updates during `extraction_operations` mode. + +#### Scenario: Additive schema change in extraction mode +- GIVEN a knowledge graph in `extraction_operations` +- WHEN a user or agent adds a new property or type +- THEN the change is accepted through mutation logs +- AND extraction operations continue using the updated schema + +### Requirement: Prepopulated Type Semantics +The system SHALL enforce `prepopulated=true` as a transition-blocking readiness constraint for entity and relationship types. + +#### Scenario: Prepopulated entity type with instances +- GIVEN an entity type marked `prepopulated=true` +- WHEN readiness is evaluated +- THEN the type passes only if it has one or more instances + +#### Scenario: Prepopulated entity type without instances +- GIVEN an entity type marked `prepopulated=true` with zero instances +- WHEN readiness is evaluated +- THEN validation fails and transition to extraction mode is blocked + +#### Scenario: Prepopulated relationship type with prepopulated endpoints +- GIVEN a relationship type marked `prepopulated=true` +- AND every listed source and target entity type is marked `prepopulated=true` +- WHEN the ontology is saved +- THEN the save succeeds + +#### Scenario: Prepopulated relationship type without prepopulated endpoints +- GIVEN a relationship type marked `prepopulated=true` +- AND at least one source or target entity type is not marked `prepopulated=true` +- WHEN the ontology is saved +- THEN validation fails with a clear error + +#### Scenario: Prepopulated relationship type without instances +- GIVEN a relationship type marked `prepopulated=true` with zero instances +- WHEN readiness is evaluated +- THEN validation fails and transition to extraction mode is blocked + +### Requirement: Opinionated Bootstrap Workflow +The system SHALL guide the Graph Management Assistant through a six-phase schema bootstrap workflow. + +#### Scenario: Goals before schema +- GIVEN a new schema bootstrap conversation +- WHEN the assistant begins intake +- THEN it asks for questions the graph must answer before proposing entity types + +#### Scenario: Phased bootstrap guidance +- GIVEN schema bootstrap skills are resolved for a graph-management turn +- WHEN the agent system prompt is assembled +- THEN it includes the six phases: goals, discovery, schema Q&A, prepopulation planning, confirmed save, bulk implementation + +#### Scenario: Confirmed ontology save +- GIVEN the assistant has drafted a schema but the user has not confirmed it +- WHEN the assistant considers persisting types +- THEN guardrails require waiting for explicit user confirmation before `kartograph_save_schema_ontology` + +#### Scenario: Property versus entity modeling guidance +- GIVEN schema bootstrap skills are resolved +- WHEN the assistant models attributes +- THEN skills distinguish categorize/distinguish → property from track-which/needs-relationships → entity type + +#### Scenario: Workspace discovery before prepopulation +- GIVEN the assistant enters prepopulation planning +- WHEN skills are resolved +- THEN prepopulation guidance requires Glob/Grep discovery on `repository-files/` first + +#### Scenario: Execute-first prepopulation after schema save +- GIVEN the ontology is saved and readiness shows prepopulated entity or relationship gaps +- WHEN the Graph Management Assistant continues schema bootstrap +- THEN it executes one prepopulation task per turn via generator script and apply-from-file +- AND does not ask the user for permission to proceed unless strategy is ambiguous or CREATE is rejected + +#### Scenario: Entities before relationships during prepopulation +- GIVEN readiness shows both prepopulated entity gaps and prepopulated relationship gaps +- WHEN the assistant implements prepopulation +- THEN it authors and runs entity scanner scripts for every entity gap before any relationship scanner +- AND each scanner discovers instances across all `repository-files/` data sources + +### Requirement: Workload Bulk Instance Authoring +The system SHALL support bulk instance authoring for the Graph Management Assistant via workspace files and strict CREATE semantics. + +#### Scenario: Dry-run mutation validation +- GIVEN a JSONL batch of mutation lines for one knowledge graph +- WHEN the assistant calls workload mutation validate +- THEN the system returns validation errors without writing to the graph +- AND CREATE lines that target existing instance ids or slugs are rejected + +#### Scenario: Apply mutations from workspace file +- GIVEN a JSONL file under the sticky session workspace mount +- WHEN the assistant applies mutations from that file path +- THEN the system reads the full file and applies all valid operations in one request + +#### Scenario: Session workspace generator templates +- GIVEN a sticky session work directory is prepared +- WHEN the assistant lists `instance_generators/` +- THEN `_entity_scanner.example.py`, `entities_to_jsonl.py`, and `relationships_to_jsonl.py` are present +- AND the assistant authors `{label}.py` scanners that emit `out/{label}_instances.json` + +#### Scenario: Batch entity prepopulation pipeline +- GIVEN a prepopulated entity type with a readiness gap +- WHEN the assistant runs `{label}.py` and `entities_to_jsonl.py` +- THEN it produces `instance_generators/out/{label}_instances.jsonl` +- AND applies all CREATE lines in one validate/apply-from-file batch + +### Requirement: Bidirectional Relationship Pairing +The system SHALL default new relationship types to bidirectional pairing. See [Bidirectional Relationships](bidirectional-relationships.spec.md). + +#### Scenario: Ontology save creates inverse type +- GIVEN a primary relationship type with `bidirectional=true` +- WHEN the ontology is saved +- THEN the inverse relationship type is stored with swapped endpoints + +#### Scenario: Primary edge CREATE expands to twin +- GIVEN a bidirectional relationship type exists in the ontology +- WHEN a primary-direction edge CREATE mutation is applied via workload tools +- THEN an inverse edge CREATE is applied in the same batch + diff --git a/specs/index.spec.md b/specs/index.spec.md index a28e9c70e..44dcce3c9 100644 --- a/specs/index.spec.md +++ b/specs/index.spec.md @@ -29,6 +29,8 @@ The persistence and query engine for property graph data. | [Mutations](graph/mutations.spec.md) | Applying mutation logs to the graph | | [Queries](graph/queries.spec.md) | Reading nodes, edges, and subgraphs | | [Schema](graph/schema.spec.md) | Type definitions and schema management | +| [Schema Authoring](graph/schema-authoring.spec.md) | Bootstrap and ongoing schema authoring lifecycle | +| [Bidirectional Relationships](graph/bidirectional-relationships.spec.md) | Paired inverse relationship types and twin edge instances | | [Bulk Loading](graph/bulk-loading.spec.md) | High-throughput graph ingestion | ### [Management](management/) — Control Plane @@ -37,6 +39,7 @@ CRUD for platform resources: knowledge graphs, data sources, credentials. | Spec | Scope | |------|-------| | [Knowledge Graphs](management/knowledge-graphs.spec.md) | Knowledge graph configuration lifecycle | +| [Knowledge Graph Workspace](management/knowledge-graph-workspace.spec.md) | Knowledge graph mode lifecycle and workspace status | | [Data Sources](management/data-sources.spec.md) | Data source configuration and sync runs | | [Credentials](management/credentials.spec.md) | Encrypted credential storage | @@ -56,6 +59,16 @@ Connecting to external sources, detecting changes, and packaging raw content for | [Adapters](ingestion/adapters.spec.md) | Adapter port, GitHub adapter, dlt framework integration | | [Sync Lifecycle](ingestion/sync-lifecycle.spec.md) | Event-driven state machine, status tracking, staleness detection | +### [Extraction](extraction/) — Agent-Orchestrated Mutation Production +AI-assisted schema and extraction workflows that emit MutationLogs for Graph application. + +| Spec | Scope | +|------|-------| +| [Operations](extraction/operations.spec.md) | Mode-specific agent operations and mutation-log production | +| [Agent Sessions](extraction/agent-sessions.spec.md) | Session lifecycle, reset behavior, and session metrics | +| [Chat Turns](extraction/chat-turns.spec.md) | Graph-management chat streaming, wait states, and turn persistence | +| [Sticky Session Runtime](extraction/sticky-session-runtime.spec.md) | Isolated sticky containers, JobPackage context, Claude Agent SDK runtime | + ### [Shared Kernel](shared-kernel/) — Cross-Cutting Contracts Capabilities shared across bounded contexts. @@ -88,3 +101,4 @@ The web interface for platform setup, data source management, and graph explorat | [CORS](nfr/cors.spec.md) | Cross-origin resource sharing policy | | [Application Lifecycle](nfr/application-lifecycle.spec.md) | Startup bootstrap, shutdown, default configuration | | [API Conventions](nfr/api-conventions.spec.md) | URL structure, status codes, error format, request/response models | +| [Workload Execution](nfr/workload-execution.spec.md) | Container execution model, credential injection, and workload isolation | diff --git a/specs/ingestion/sync-lifecycle.spec.md b/specs/ingestion/sync-lifecycle.spec.md index c2713ec96..07fe77bef 100644 --- a/specs/ingestion/sync-lifecycle.spec.md +++ b/specs/ingestion/sync-lifecycle.spec.md @@ -70,6 +70,28 @@ The system SHALL support both manual and scheduled sync triggers. - WHEN the schedule fires - THEN a sync is initiated as if manually triggered +### Requirement: Commit-Baseline-Aware Ingestion +The system SHALL maintain commit-aware ingestion context for Git-backed sources. + +#### Scenario: Baseline at extraction start +- GIVEN a Git-backed data source with a local clone +- WHEN a sync run starts +- THEN the run baseline is set to `commit_during_last_extraction` +- AND incremental extraction compares current source state against that baseline + +#### Scenario: Branch head refresh for ingestion readiness +- GIVEN a Git-backed data source with a tracked branch +- WHEN sync orchestration prepares ingestion context +- THEN the latest tracked branch HEAD is resolved and stored as `tracked_branch_head_commit` +- AND ingestion context for that run is prepared from the corresponding latest files + +#### Scenario: No-new-commit outcome +- GIVEN `tracked_branch_head_commit` equals `commit_during_last_extraction` +- WHEN a sync run is requested +- THEN the system may short-circuit heavy extraction work +- AND a sync run record is still created for auditability +- AND run status and logs indicate no source changes were detected + ### Requirement: Staleness-Based Node Lifecycle The system SHALL use timestamp comparison to detect stale graph nodes instead of explicit delete events. diff --git a/specs/management/data-sources.spec.md b/specs/management/data-sources.spec.md index fe056dceb..da21a5ce3 100644 --- a/specs/management/data-sources.spec.md +++ b/specs/management/data-sources.spec.md @@ -126,6 +126,36 @@ The system SHALL track the execution status of each sync operation. - WHEN the data source is deleted - THEN all associated sync runs are cascade-deleted +### Requirement: Source Commit Reference Tracking +The system SHALL track source-repository commit references for Git-based data sources. + +#### Scenario: Local clone commit tracking +- GIVEN a Git-backed data source with a local clone available to ingestion tooling +- WHEN source commit references are refreshed +- THEN a clone-head commit reference is recorded as the ingestion clone HEAD for the tracked branch + +#### Scenario: Commit during last extraction tracking +- GIVEN a sync run starts for a Git-backed data source +- WHEN extraction begins +- THEN a last-extraction baseline commit reference is recorded from local clone state at run start +- AND this value remains fixed for that run even if branch HEAD changes later + +#### Scenario: Tracked branch head commit tracking +- GIVEN a Git-backed data source configured with a tracked branch +- WHEN source commit references are refreshed +- THEN a tracked-branch head commit reference is recorded from the latest known remote branch HEAD + +#### Scenario: UI label compatibility +- GIVEN commit references are displayed in the UI +- WHEN labels are rendered +- THEN labels may use either legacy terms ("Local clone commit", "Commit during last extraction") or clearer equivalents +- AND displayed labels map unambiguously to clone-head, last-extraction-baseline, and tracked-branch-head references + +#### Scenario: Adapter scope +- GIVEN a non-Git adapter type +- WHEN source commit references are requested +- THEN Git-specific commit fields are absent or null + ### Requirement: Adapter Connection Config Normalization Each adapter SHALL accept user-friendly connection parameters and normalize them internally. diff --git a/specs/management/knowledge-graph-workspace.spec.md b/specs/management/knowledge-graph-workspace.spec.md new file mode 100644 index 000000000..32840c832 --- /dev/null +++ b/specs/management/knowledge-graph-workspace.spec.md @@ -0,0 +1,65 @@ +# Knowledge Graph Workspace + +## Purpose +A knowledge graph workspace provides a mode-aware control surface for progressing from initial schema bootstrap to ongoing extraction and mutation operations. It exposes lifecycle state, readiness checks, and navigation contracts consumed by the UI and extraction agents. + +## Requirements + +### Requirement: Workspace Mode Lifecycle +The system SHALL track each knowledge graph in one of two modes: `schema_bootstrap` and `extraction_operations`. + +#### Scenario: Default mode on creation +- GIVEN a newly created knowledge graph +- WHEN the knowledge graph record is persisted +- THEN its workspace mode is `schema_bootstrap` + +#### Scenario: Irreversible transition +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN the user completes validation and transitions to extraction operations +- THEN the mode changes to `extraction_operations` +- AND the mode cannot be changed back to `schema_bootstrap` + +### Requirement: Workspace Status Projection +The system SHALL expose a knowledge-graph workspace status projection for UI rendering. + +#### Scenario: Status includes mode and readiness +- GIVEN a knowledge graph workspace request +- WHEN the status projection is returned +- THEN it includes current mode, validation readiness flags, and a transition eligibility flag + +#### Scenario: Status includes session pointers +- GIVEN one or more extraction agent sessions associated with the knowledge graph +- WHEN the status projection is returned +- THEN it includes pointers to the current active session per mode and the most recent completed session + +### Requirement: Bootstrap Readiness Validation +The system SHALL define schema bootstrap readiness checks for transition eligibility. + +#### Scenario: Minimum schema readiness +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN readiness is evaluated +- THEN validation fails unless there is at least one entity type and at least one relationship type + +#### Scenario: Prepopulated instance readiness +- GIVEN one or more entity or relationship types marked `prepopulated=true` +- WHEN readiness is evaluated +- THEN validation fails if any such type has zero instances + +#### Scenario: Prepopulated relationship endpoint constraint +- GIVEN a relationship type marked `prepopulated=true` +- WHEN the ontology is saved +- THEN every listed source and target entity type must also be marked `prepopulated=true` + +### Requirement: Transition Authorization +The system SHALL require `edit` permission on the knowledge graph for bootstrap validation and mode transition. + +#### Scenario: Authorized validate and transition +- GIVEN a user with `edit` permission on the knowledge graph +- WHEN the user invokes validate and transition actions +- THEN both actions are permitted + +#### Scenario: Unauthorized validate and transition +- GIVEN a user without `edit` permission on the knowledge graph +- WHEN the user invokes validate or transition actions +- THEN the action is rejected with a forbidden error + diff --git a/specs/nfr/workload-execution.spec.md b/specs/nfr/workload-execution.spec.md new file mode 100644 index 000000000..ed11899c5 --- /dev/null +++ b/specs/nfr/workload-execution.spec.md @@ -0,0 +1,73 @@ +# Workload Execution + +NFR: This spec describes execution, isolation, and credential-injection constraints for agent workloads. + +## Purpose +Kartograph executes extraction agent workloads in containers with a hybrid model: sticky conversational containers per session and ephemeral worker containers for extraction execution. Runtime credentials are injected securely and scoped with least privilege. + +## Requirements + +### Requirement: Container-Only Agent Runtime +The system SHALL run extraction agents in containers for both local development and deployed environments. + +#### Scenario: Local development execution +- GIVEN local development workflows +- WHEN extraction agents are started +- THEN they run inside local containers rather than host-native processes + +#### Scenario: Deployed execution +- GIVEN a deployed environment +- WHEN extraction workloads are started +- THEN they run in pod containers managed by the platform + +### Requirement: Hybrid Container Model +The system SHALL use sticky containers for chat sessions and ephemeral containers for extraction execution workers. + +#### Scenario: Sticky session container +- GIVEN a user starts an extraction chat session +- WHEN the session remains active +- THEN the session reuses the same container context until clear/reset or timeout + +#### Scenario: Ephemeral execution workers +- GIVEN extraction jobs are launched +- WHEN worker tasks execute +- THEN they run in ephemeral worker containers +- AND worker containers are terminated after job completion or failure + +### Requirement: Runtime Credential Injection +The system SHALL provide runtime credentials to agent containers through secure injection. + +#### Scenario: Workload authentication material +- GIVEN a workload container requires access to platform services +- WHEN the workload starts +- THEN short-lived authentication credentials are injected at runtime +- AND credentials are not hardcoded in repository files, container images, or mutation logs + +#### Scenario: Least-privilege scope +- GIVEN an extraction workload for a knowledge graph +- WHEN credentials are issued +- THEN permissions are limited to required tenant and knowledge-graph scope operations + +### Requirement: Skill and Context Availability +The system SHALL provide required runtime context in workload containers. + +#### Scenario: Built-in context +- GIVEN an extraction workload container +- WHEN the workload initializes +- THEN ingestion context resources and repository files needed for processing are available +- AND the skills directory is available to the agent runtime + +#### Scenario: Sticky session Claude agent runtime +- GIVEN a sticky session container for graph-management chat +- WHEN the container starts +- THEN it hosts a Claude Agent SDK agent instance isolated from the API process +- AND JobPackage material may be mounted when ingestion context is required for the active graph-management mode + +### Requirement: Graph Management UI Mode Skills +The system SHALL expose graph-management UI mode skill overlays in addition to workspace session mode skills. + +#### Scenario: UI mode overlays +- GIVEN graph-management modes `Initial Schema Design`, `Extraction Jobs`, and `One-off Mutations` +- WHEN skill instructions are resolved for a chat turn +- THEN UI mode overlays adjust assistant framing while preserving workspace session mode guardrails + diff --git a/specs/ui/experience.spec.md b/specs/ui/experience.spec.md index eb43171e4..4f35fb42c 100644 --- a/specs/ui/experience.spec.md +++ b/specs/ui/experience.spec.md @@ -67,17 +67,19 @@ The system SHALL guide users through creating a knowledge graph before adding da ### Requirement: Data Source Connection The system SHALL provide a guided flow for connecting external data sources to a knowledge graph. -#### Scenario: Adapter type selection +#### Scenario: URL-first provider detection - GIVEN a user adding a data source to a knowledge graph - WHEN the flow begins -- THEN the user selects an adapter type first (e.g., GitHub) -- AND the form adapts to show adapter-specific fields +- THEN the user can add multiple source URLs using repeated URL input rows (`Add another`) +- AND the system auto-detects the provider type from the URL (GitHub, GitLab, Jira) +- AND unsupported providers are clearly marked as coming soon without allowing completion #### Scenario: Connection configuration -- GIVEN a selected adapter type (e.g., GitHub) +- GIVEN a detected GitHub provider - WHEN the user configures the connection -- THEN they provide the minimum required fields (e.g., repository URL, access token) -- AND the system infers defaults where possible (e.g., data source name from repo name) +- THEN they provide the minimum required fields (knowledge graph, repository URL, tracked branch, and source name) +- AND the system infers defaults where possible (e.g., data source name from repo name and default branch from repository metadata) +- AND credentials are entered in a single one-time token field #### Scenario: Credential handling - GIVEN credentials provided during data source setup @@ -86,26 +88,7 @@ The system SHALL provide a guided flow for connecting external data sources to a - AND the plaintext is never persisted in the browser ### Requirement: Ontology Design -The system SHALL support an agent-assisted ontology design flow when connecting a data source. - -#### Scenario: Intent description -- GIVEN a user who has connected a data source -- WHEN the connection is saved -- THEN the user is prompted to describe (in free text) what problems or questions they want to solve with this data - -#### Scenario: Agent-proposed ontology -- GIVEN a free-text intent description and a connected data source -- WHEN the user submits their intent -- THEN the system performs a lightweight scan of the data source -- AND an AI agent explores the scanned data and proposes an ontology (node types, edge types, properties) -- AND the proposed ontology is presented to the user for review - -#### Scenario: Ontology review and approval -- GIVEN a proposed ontology -- WHEN the user reviews it -- THEN they can approve the ontology as-is -- OR iterate by editing individual types and relationships -- AND extraction begins only after the user explicitly approves +The system SHALL support an editable ontology experience for connected data sources. #### Scenario: Individual type editing - GIVEN a proposed or existing ontology @@ -144,6 +127,24 @@ The system SHALL show sync progress and status for each data source. - WHEN the user triggers a sync - THEN a new sync run begins and progress is shown +#### Scenario: Commit-hash status cues +- GIVEN a Git-backed data source card in the UI +- WHEN commit reference data is available +- THEN the UI displays `Local clone commit`, `Commit during last extraction`, and tracked branch head commit values +- AND the UI visually indicates whether new commits are available since the last extraction baseline + +#### Scenario: Maintenance-readiness cue +- GIVEN a Git-backed data source where tracked branch head differs from commit during last extraction +- WHEN the user views data source status +- THEN the UI highlights that maintenance/extraction work can be run for new source changes + +#### Scenario: Diff summary cue +- GIVEN a Git-backed data source with commit references for baseline and latest tracked branch head +- WHEN the user opens sync/maintenance details +- THEN the UI shows a diff summary relative to the last extraction baseline suitable for deciding whether to run maintenance +- AND the summary includes aggregate counts and a changed-file list +- AND the changed-file list is collapsed by default and expanded on demand to avoid overwhelming the page + ### Requirement: Get Started Querying (MCP Connection) The system SHALL make it easy for users to connect AI agents to their knowledge graph via MCP. @@ -511,3 +512,30 @@ The system SHALL support light and dark color schemes. - GIVEN the user interface - THEN a dark mode toggle is available in the header - AND the preference persists across sessions + +### Requirement: Knowledge Graph Manage Actions +The system SHALL expose knowledge graph row actions as Manage, Query, and Delete. + +#### Scenario: Knowledge graph action set +- GIVEN the knowledge graph list +- THEN each knowledge graph row shows actions for Manage, Query, and Delete +- AND legacy actions not in this set are not shown in the row action cluster + +#### Scenario: Manage navigation +- GIVEN a user clicks Manage on a knowledge graph row +- WHEN navigation completes +- THEN the user lands on that knowledge graph's mode-aware workspace page + +### Requirement: Detailed KG Manage Experience Specification +The system SHALL define detailed KG Manage workspace behavior in a dedicated canonical UX spec to avoid drift. + +#### Scenario: Canonical detailed behavior source +- GIVEN requirements for the graph manage page flow, conversation UX, modes, and step cards +- THEN details are defined in `specs/ui/kg-manage-experience.spec.md` +- AND this file remains the high-level UX umbrella for broader product behavior + +#### Scenario: Cross-spec consistency +- GIVEN updates to KG manage interaction behavior +- WHEN UX requirements are changed +- THEN `specs/ui/kg-manage-experience.spec.md` is updated first +- AND summary references here are kept consistent with it diff --git a/specs/ui/kg-manage-experience.spec.md b/specs/ui/kg-manage-experience.spec.md new file mode 100644 index 000000000..86871b238 --- /dev/null +++ b/specs/ui/kg-manage-experience.spec.md @@ -0,0 +1,238 @@ +# Knowledge Graph Manage Experience + +## Purpose +Define the canonical UX for the Knowledge Graph `Manage` flow in Kartograph, modeled after the proven interaction patterns in k-extract project workspace and design pages. + +This spec is the detailed source of truth for KG management UI behavior. + +## Scope +In scope: +- `Knowledge Graphs -> Manage` entry flow. +- KG workspace layout, step cards, and progress semantics. +- Graph Management conversation-first interaction model. +- Mode switch behavior and lower-panel content contracts. +- Error/loading/empty/forbidden states and keyboard interactions. + +Out of scope: +- Backend domain rules already specified in management/extraction/graph specs. +- Container runtime implementation details. + +## Page Contracts +### Page: KG Manage Workspace Overview +Route: `/knowledge-graphs/{kgId}/manage` + +Primary intent: +- Provide a project-workspace-style control center for the selected graph. +- Help the user decide the next action with minimal navigation overhead. + +Top-level regions: +- Header (graph name/identity, back action) +- `Project workspace` section +- `Suggested next step` callout with one primary CTA +- Step-card grid (`Data Sources`, `Graph Management`, `MutationLogs`, `Maintain`) + +### Page: KG Graph Management +Route: `/knowledge-graphs/{kgId}/manage` (same page surface, `Graph Management` active state) + +Primary intent: +- Keep conversation as the main control surface. +- Support three operation modes without session fragmentation. + +Top-level regions: +- Graph Management mode switcher +- Shared persistent chat box +- Hybrid lower panel: + - left rail: status/artifacts + - right detail panel: mode-specific workspace + +## Requirements + +### Requirement: Manage Entry Navigation +The system SHALL route users from knowledge graph list rows into a graph-scoped manage workspace. + +#### Scenario: Manage route entry +- GIVEN the user is on the Knowledge Graphs list +- WHEN the user clicks `Manage` for a knowledge graph +- THEN navigation lands on `/knowledge-graphs/{kgId}/manage` +- AND the page header includes graph identity and a back action +- AND the selected graph context is available to all step cards without re-selection + +### Requirement: Workspace Shell and Step Cards +The system SHALL provide a project-workspace-style shell with actionable step cards. + +#### Scenario: Step card set +- GIVEN the user opens KG manage workspace +- THEN the step card grid contains exactly: + - `Data Sources` + - `Graph Management` + - `MutationLogs` + - `Maintain` + +#### Scenario: Suggested next step +- GIVEN workspace status and run metadata are available +- WHEN the manage page renders +- THEN a `Suggested next step` callout is shown above the card grid +- AND the callout CTA routes to the corresponding step destination +- AND the CTA label uses action wording (`Open`, `Revisit`, or `Run`) + +#### Scenario: Card status semantics +- GIVEN each step has completion/readiness metadata +- WHEN cards render +- THEN each card displays status tint and label (`ready`, `in_progress`, `needs_attention`, or `blocked`) +- AND each card includes one primary action (`Open` or `Revisit`) +- AND each card includes one line of status detail text suitable for quick scanning + +### Requirement: Data Sources Step Behavior +The system SHALL preserve the established data-source operations experience while keeping graph context. + +#### Scenario: Graph-scoped data source step +- GIVEN the user opens `Data Sources` from KG manage workspace +- THEN the destination is pre-scoped to the selected knowledge graph +- AND source onboarding and source-level commit/diff cues remain available in that scoped view +- AND graph-wide maintenance orchestration and run telemetry remain in `Manage` +- AND returning to manage workspace preserves the current graph context + +### Requirement: Graph Management Conversation-First Layout +The system SHALL use a single persistent chat panel as the primary control surface. + +#### Scenario: Persistent shared chat +- GIVEN the user is in Graph Management +- WHEN the user changes modes +- THEN chat history remains in the same session scope +- AND the active mode changes assistant skill framing/instructions rather than opening a new chat +- AND the input placeholder/help text updates to reflect the selected mode + +#### Scenario: Top-section controls +- GIVEN the Graph Management page +- THEN the top section includes: + - mode switcher + - clear chat action + - session status indicator + - validation affordance when relevant to mode +- AND these controls are visible without scrolling on desktop layout + +### Requirement: Graph Management Modes +The system SHALL support three operator modes on one page. + +#### Scenario: Supported modes +- GIVEN the mode selector in Graph Management +- THEN available modes are: + - `Initial Schema Design` + - `Extraction Jobs` + - `One-off Mutations` + +#### Scenario: Mode-specific AI behavior +- GIVEN the user selects a mode +- WHEN the assistant responds or suggests next actions +- THEN the assistant uses mode-appropriate skills and guidance +- AND does not lose shared conversational context +- AND assistant suggestions are constrained to the current knowledge graph scope + +### Requirement: Hybrid Lower Panel +The system SHALL provide a hybrid lower panel with shared status/artifact rail and mode-specific detail panel. + +#### Scenario: Shared rail +- GIVEN the Graph Management lower panel +- THEN a persistent rail shows graph-management status/artifact items relevant across modes +- AND each item includes status plus last-updated metadata +- AND rail items support keyboard focus and selection + +#### Scenario: Mode-specific detail panel +- GIVEN a selected mode and selected rail item +- THEN the right-side detail panel renders mode-specific content: + - `Initial Schema Design`: schema artifacts, readiness blockers, validation controls + - `Extraction Jobs`: job setup, execution controls, and job run context + - `One-off Mutations`: mutation authoring controls and submit/preview context +- AND switching modes preserves rail selection when the selected item is valid in the new mode + +#### Scenario: Schema design parity behavior +- GIVEN `Initial Schema Design` mode is active +- THEN the lower panel exposes schema-focused artifact/status content analogous to k-extract design-artifact workflow +- AND the user can inspect and revise schema-related content without leaving Graph Management + +### Requirement: MutationLogs Step Experience +The system SHALL provide graph-scoped mutation run visibility. + +#### Scenario: Graph-scoped mutation run list +- GIVEN the user opens the `MutationLogs` step +- THEN only runs for the selected knowledge graph are listed +- AND list items show status, timestamp, source, and run identifier +- AND the list defaults to newest run first + +#### Scenario: Run detail richness +- GIVEN a selected run +- THEN the detail panel shows run summary, session reference, token/cost metrics, and operation-class counts +- AND supports per-entry operation preview when available +- AND gracefully displays a no-preview state when detailed entries are unavailable + +### Requirement: Maintain Step Experience +The system SHALL provide incremental maintenance entry points from the manage workspace. + +#### Scenario: Maintenance readiness actioning +- GIVEN tracked source changes are detected +- WHEN the user opens `Maintain` +- THEN the UI highlights change readiness and provides the maintenance execution path +- AND relevant diff summary context is available before execution +- AND the user can navigate back to workspace overview without losing step status context + +### Requirement: Session and Reset Behavior +The system SHALL support explicit conversational reset without losing auditability. + +#### Scenario: Clear chat reset +- GIVEN an active graph-management chat +- WHEN the user clicks `Clear chat` +- THEN the current chat thread resets +- AND a new clean session starts for the same user/knowledge-graph scope +- AND historical session records remain available for audit/history views +- AND mode selection remains unchanged after reset + +### Requirement: State and Accessibility Contracts +The system SHALL provide predictable state handling and keyboard affordances. + +#### Scenario: Loading and empty states +- GIVEN initial page load or step data fetch +- THEN each major section shows explicit loading placeholders +- AND empty states provide direct next actions +- AND loading/error state messaging is step-specific (not generic across all steps) + +#### Scenario: Forbidden state +- GIVEN the user lacks required permission for a step action +- WHEN the action is attempted +- THEN the UI shows a clear forbidden state/message +- AND avoids partial, misleading updates +- AND disabled actions explain why access is restricted + +#### Scenario: Keyboard behavior +- GIVEN the chat input is focused +- THEN `Enter` sends and `Shift+Enter` inserts newline +- AND mode switch/step navigation remains keyboard reachable +- AND primary step-card actions can be triggered by keyboard focus + Enter/Space + +## Traceability to UI Surfaces + +Primary surfaces expected to implement this UX: +- `src/dev-ui/app/pages/knowledge-graphs/index.vue` +- `src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue` +- `src/dev-ui/app/components/extraction/SharedConversationPanel.vue` + +Requirement-to-surface mapping: +- Manage Entry Navigation -> `knowledge-graphs/index.vue` +- Workspace Shell and Step Cards -> `knowledge-graphs/[kgId]/manage.vue` +- Graph Management Conversation-First Layout -> `knowledge-graphs/[kgId]/manage.vue`, `SharedConversationPanel.vue` +- Graph Management Modes -> `knowledge-graphs/[kgId]/manage.vue` +- Hybrid Lower Panel -> `knowledge-graphs/[kgId]/manage.vue` +- MutationLogs Step Experience -> `knowledge-graphs/[kgId]/manage.vue` +- Maintain Step Experience -> `knowledge-graphs/[kgId]/manage.vue` (+ data-source operations surface) +- Session and Reset Behavior -> `knowledge-graphs/[kgId]/manage.vue`, `SharedConversationPanel.vue` +- State and Accessibility Contracts -> `knowledge-graphs/[kgId]/manage.vue`, `SharedConversationPanel.vue` + +## Issue Mapping +Detailed implementation tracking for this spec has been externalized to GitHub issues: +- `#722` workspace overview parity +- `#723` graph-management parity (shared chat + mode switch + hybrid panel) +- `#724` mutationlogs step hardening +- `#725` accessibility and state contracts + +## Notes for Issue Alignment +- In-place unified operations parity is tracked by GitHub issue `#720`. +- Per-run operation preview depth is tracked by GitHub issue `#721`. diff --git a/src/agent-runtime/Dockerfile b/src/agent-runtime/Dockerfile new file mode 100644 index 000000000..e04e56864 --- /dev/null +++ b/src/agent-runtime/Dockerfile @@ -0,0 +1,29 @@ +FROM registry.access.redhat.com/ubi9/python-312:latest + +USER 0 + +WORKDIR /app + +COPY --from=ghcr.io/astral-sh/uv:0.9.18 /uv /uvx /bin/ + +COPY pyproject.toml uv.lock /app/ +COPY kartograph_agent_runtime /app/kartograph_agent_runtime + +RUN uv sync --frozen --no-dev + +# OpenShell sandboxes require a dedicated non-root identity in the image. +RUN groupadd -r -g 65532 sandbox \ + && useradd -r -u 65532 -g sandbox -d /sandbox -s /bin/bash sandbox \ + && mkdir -p /sandbox /workspace \ + && chown sandbox:sandbox /sandbox /workspace \ + && chmod -R a+rX /app + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 + +EXPOSE 8787 + +HEALTHCHECK --interval=15s --timeout=3s --start-period=30s --retries=5 \ + CMD pgrep -f "python -m kartograph_agent_runtime" >/dev/null || exit 1 + +CMD ["/app/.venv/bin/python", "-m", "kartograph_agent_runtime"] diff --git a/src/agent-runtime/kartograph_agent_runtime/__init__.py b/src/agent-runtime/kartograph_agent_runtime/__init__.py new file mode 100644 index 000000000..89889fd90 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/__init__.py @@ -0,0 +1 @@ +"""Kartograph sticky session Claude Agent SDK runtime.""" diff --git a/src/agent-runtime/kartograph_agent_runtime/__main__.py b/src/agent-runtime/kartograph_agent_runtime/__main__.py new file mode 100644 index 000000000..13ce20acb --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/__main__.py @@ -0,0 +1,21 @@ +"""CLI entrypoint for sticky session agent runtime.""" + +from __future__ import annotations + +import uvicorn + +from kartograph_agent_runtime.settings import AgentRuntimeSettings + + +def main() -> None: + settings = AgentRuntimeSettings() + uvicorn.run( + "kartograph_agent_runtime.server:app", + host=settings.host, + port=settings.port, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/src/agent-runtime/kartograph_agent_runtime/agent_prompt.py b/src/agent-runtime/kartograph_agent_runtime/agent_prompt.py new file mode 100644 index 000000000..cc276b3d7 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/agent_prompt.py @@ -0,0 +1,375 @@ +"""System prompt assembly for the Graph Management Assistant.""" + +from __future__ import annotations + +from typing import Any, Literal + +PromptDetail = Literal["full", "compact"] + +from kartograph_agent_runtime.extraction_jobs_tools import KARTOGRAPH_EXTRACTION_JOBS_TOOL_NAMES +from kartograph_agent_runtime.schema_tools import ( + KARTOGRAPH_SCHEMA_TOOL_NAMES, + WORKSPACE_FILE_TOOL_NAMES, +) +from kartograph_agent_runtime.settings import AgentRuntimeSettings + +_TOOLS_QUICK_REFERENCE = """ +## Kartograph schema tools (always use these — never probe HTTP routes) + +| Tool | Purpose | +|------|---------| +| `kartograph_get_schema_authoring_guide` | Full JSON shapes, instance cookbook, mutation rules — call first | +| `kartograph_get_workspace_readiness` | Prepopulated gaps, live instance counts, blocking reasons | +| `kartograph_get_schema_ontology` | Read current `node_types` and `edge_types` before every save | +| `kartograph_save_schema_ontology` | Replace canonical ontology (read → merge edits → save full payload) | +| `kartograph_validate_graph_mutations` | Dry-run JSONL (strict CREATE — no duplicates) | +| `kartograph_apply_graph_mutations` | Apply JSONL CREATE/UPDATE/DELETE (small batches) | +| `kartograph_validate_graph_mutations_from_file` | Dry-run a workspace `.jsonl` file | +| `kartograph_apply_graph_mutations_from_file` | Apply a workspace `.jsonl` file in one call | +| `kartograph_list_instances_by_type` | List instances with mutation-ready `id`, `slug`, `properties` (paginate for bulk) | +| `kartograph_list_relationship_instances` | List relationship edges with source/target slugs and node IDs | +| `kartograph_search_graph_by_slug` | Find existing nodes by slug to avoid duplicates | +| `kartograph_check_graph_slugs` | Batch check which slugs already exist for one entity type | + +## Workspace tools + +| Tool | Purpose | +|------|---------| +| `Read` | Read files under the session workspace mount | +| `Write` | Create scanner scripts and JSON outputs under `instance_generators/` | +| `Edit` | Update existing workspace files (e.g. refine a scanner script) | +| `Grep` | Search file contents in `repository-files//` | +| `Glob` | List files by pattern for instance generation | +| `Bash` | Run scanners and `preview_instances.py` against `repository-files/` | + +See `instance_generators/PREPOPULATION_WORKFLOW.md` for the numbered prepopulation checklist. + +### Quick workflow + +1. `kartograph_get_schema_authoring_guide` +2. `kartograph_get_workspace_readiness` +3. `kartograph_get_schema_ontology` +4. Prepopulation: `run_scanner.py {Label} --entity` → apply-from-file (or manual JSONL pipeline) +5. Model types → `kartograph_save_schema_ontology` +6. Apply CREATE mutations → `kartograph_apply_graph_mutations_from_file` (apply returns `next_action`) +7. Create relationship edges after entity IDs are known +8. Verify with `kartograph_list_instances_by_type` and readiness when needed + +### Failure modes (stop prepopulation on infra errors) + +- **422** — fix ontology or JSONL; retry is appropriate. +- **500/503 on readiness or apply after validate passed** — platform/graph storage issue; **stop**, report to the operator, do not advance to the next prepopulated label. Suggest `make dev-repair-age-graphs` in local dev. +- **`approved_at: null`** — optional; does **not** block prepopulation. +- **Validate pass + apply 500/503** — backend bug; report both outcomes; do not skip to the next entity type. + +Start prepopulation only when schema save succeeded **and** readiness returns 200 with gaps. + +**Relationship ontology:** each `edge_types[].label` must be unique. Multiple UI rows (one per +source→target pair) require distinct labels (e.g. `tests_ct_api`, `covered_by_us_e2e`). After save, +read back `kartograph_get_schema_ontology` — do not claim N types until N primary labels are stored. + +Writes persist to the platform database for the active knowledge graph. +""".strip() + +_EXTRACTION_JOBS_TOOLS_REFERENCE = """ +## Extraction job tools (extraction-jobs UI mode) + +| Tool | Purpose | +|------|---------| +| `kartograph_get_extraction_jobs_config` | Read saved job sets, live instance counts, and `relationship_authoring_by_entity_type` | +| `kartograph_save_extraction_jobs_config` | Save job sets and regenerate pending jobs (operator-approved configs) | +| `kartograph_get_extraction_jobs_plan_summary` | Projected job counts per job set before/after save | +| `kartograph_get_extraction_jobs_status` | Queue metrics: pending/in-progress/completed/failed jobs | + +When the operator approves a job set proposal, call `kartograph_save_extraction_jobs_config` — +do not ask them to manually fill the extraction-jobs form. + +### Per-instance description (by_instances job sets) + +Before drafting, call `kartograph_get_extraction_jobs_config` and read +`entity_type_authoring_context.{EntityType}` for exact property names plus +`relationship_authoring_by_entity_type.{EntityType}` — it lists exact `owned` line prefixes +and `ignored` ignore_line text from live instance counts and the real ontology. Copy those lines; +do not invent relationship labels or property names from memory. + +Use this template (substitute real entity and relationship names): + +``` +For each of the instances of {EntityType} you've been assigned, capture everything into the knowledge graph: all properties of that instance and every relationship instance this job set owns (see lines below). + +Properties: +- {property_name}: {how to extract, where in repository-files/, value shape} +- ... + +{EntityType} -> {relationship_label} -> {CounterpartType}: {when to create/update; how to resolve counterpart slug} +(one line per entry in relationship_authoring_by_entity_type.{EntityType}.owned only) + +Ignore these relationships: +IGNORE {EntityType} -> {relationship_label} -> {CounterpartType}: handled by {CounterpartType} job sets ({counterpart_count} vs {EntityType} {entity_count} instances). Do not create or update this edge in this job set. +(one line per entry in relationship_authoring_by_entity_type.{EntityType}.ignored — never list these as active extraction targets) + +``` + +**Ownership rule:** include `{EntityType} -> {rel} -> {Counterpart}` as an active line only when +{EntityType} has MORE live instances than {Counterpart}. When the counterpart has more (or equal), +use an IGNORE line only — copy the exact lines from `relationship_authoring_by_entity_type`. + +Do **not** use theme-only sections (Implementation Analysis, Configuration Details, etc.). +When the operator approves, save via `kartograph_save_extraction_jobs_config`. +""".strip() + +_ONE_OFF_MUTATIONS_TOOLS_REFERENCE = """ +## One-off mutation tools (one-off-mutations UI mode) + +| Tool | Purpose | +|------|---------| +| `kartograph_get_schema_authoring_guide` | JSONL shapes, schema rules, one-off + bulk workflow | +| `kartograph_get_schema_ontology` | **Always read before edits** | +| `kartograph_save_schema_ontology` | Schema type/property changes (read → merge → save) | +| `kartograph_search_graph_by_slug` | Resolve **one** slug when ambiguous (avoid in bulk loops) | +| `kartograph_check_graph_slugs` | Batch slug existence before CREATE | +| `kartograph_list_instances_by_type` | **Primary bulk tool** — returns `id`, `slug`, `properties`; paginate with offset | +| `kartograph_list_relationship_instances` | Inspect edges before batch delete/create | +| `kartograph_validate_graph_mutations` | Dry-run inline JSONL (≤5 lines only) | +| `kartograph_apply_graph_mutations` | Apply inline JSONL after validate (small batches) | +| `kartograph_validate_graph_mutations_from_file` | Dry-run workspace `.jsonl` (**bulk default**) | +| `kartograph_apply_graph_mutations_from_file` | Apply larger batches from workspace file | + +Copy JSONL field names from `helpers/mutation-examples.jsonl`. For bulk work, Write to `helpers/bulk_.jsonl`. + +### Small edits (≤5 lines) + +1. Classify: schema vs instance vs read-only +2. List/search targets once +3. Validate inline → apply inline → verify + +### Bulk edits (5+ instances or delete-and-recreate) + +1. **Classify** — what to delete vs create (operator intent in plain language) +2. **Query once per type** — `kartograph_list_instances_by_type` (includes mutation-ready `id`); paginate until `total` covered; filter in Bash/python — **no per-slug search** +3. **Generate JSONL in batch** — `helpers/sync_instances.py` (current vs desired JSON) or `helpers/bulk_.jsonl`; never hand-type one line per instance +4. **Validate once → apply once** — `*_from_file` tools +5. **Verify** — one list call; report delete/create counts + +Target **2–4 tool rounds** for bulk cleanup. Confirm before schema removals only. +""".strip() + +_TOOLS_COMPACT_REFERENCE = ( + "Tools: kartograph_* schema MCP tools, plus Read/Write/Edit/Grep/Glob/Bash. " + "Prepopulation: {label}.py → out/{label}_instances.json → entities_to_jsonl.py or " + "relationships_to_jsonl.py → validate/apply out/{label}_instances.jsonl. Never /tmp." +) + + +def _format_workspace_readiness(readiness: dict[str, Any]) -> str: + lines = ["## Workspace readiness (live snapshot)"] + + next_action = str(readiness.get("next_action") or "").strip() + if next_action: + lines.append(f"- **Next action:** {next_action}") + + entity_gaps = readiness.get("prepopulated_entity_types_without_instances_live") or [] + rel_gaps = readiness.get("prepopulated_relationship_types_without_instances_live") or [] + blocking = readiness.get("blocking_reasons") or [] + prepopulated_types = readiness.get("prepopulated_entity_types") or [] + prepopulated_relationships = readiness.get("prepopulated_relationship_types") or [] + prepopulation_tasks = readiness.get("prepopulation_tasks") or [] + + if entity_gaps: + lines.append( + "- Prepopulated entity types still needing instances: " + + ", ".join(f"`{label}`" for label in entity_gaps) + ) + else: + lines.append("- All prepopulated entity types have at least one live instance.") + + if rel_gaps: + lines.append( + "- Prepopulated relationship types still needing instances: " + + ", ".join(f"`{key}`" for key in rel_gaps) + ) + + if prepopulation_tasks: + lines.append("- Prepopulation tasks:") + for task in prepopulation_tasks[:8]: + if not isinstance(task, dict): + continue + kind = str(task.get("kind") or "task") + if kind == "entity": + label = str(task.get("label") or "?") + live = task.get("live_instance_count", 0) + scanner = str(task.get("scanner_path") or "?") + lines.append(f" - `{label}` ({live} live) → create `{scanner}`") + else: + key = str(task.get("key") or "?") + live = task.get("live_instance_count", 0) + scanner = str(task.get("scanner_path") or "?") + lines.append(f" - `{key}` ({live} live) → create `{scanner}`") + + if prepopulated_types: + lines.append("- Prepopulated entity coverage:") + for row in prepopulated_types: + if not isinstance(row, dict): + continue + label = str(row.get("label") or "?") + live = row.get("live_instance_count", 0) + metadata = row.get("metadata_instance_count", 0) + required = row.get("required_properties") or [] + req_hint = f", required={list(required)}" if required else "" + lines.append(f" - `{label}`: live={live}, metadata={metadata}{req_hint}") + + if prepopulated_relationships: + lines.append("- Prepopulated relationship coverage:") + for row in prepopulated_relationships: + if not isinstance(row, dict): + continue + key = str(row.get("key") or "?") + live = row.get("live_instance_count", 0) + metadata = row.get("metadata_instance_count", 0) + lines.append(f" - `{key}`: live={live}, metadata={metadata}") + + if blocking: + lines.append("- Blocking reasons:") + for reason in blocking: + lines.append(f" - {reason}") + + transition = readiness.get("transition_eligible") + live_ready = readiness.get("prepopulated_types_ready_live") + if transition is not None: + lines.append(f"- Transition eligible: `{transition}`") + if live_ready is not None: + lines.append(f"- Prepopulated coverage ready (live): `{live_ready}`") + + return "\n".join(lines) + + +_EXTRACTION_JOBS_COMPACT_SKILL_KEYS = ("per_instance_description_authoring", "job_set_contract") + +_ONE_OFF_MUTATIONS_COMPACT_SKILL_KEYS = ( + "instance_edit_workflow", + "bulk_instance_edit_workflow", + "schema_edit_workflow", + "confirmation_policy", + "jsonl_shape_reference", +) + + +def build_agent_system_prompt( + agent_configuration: dict[str, Any], + *, + settings: AgentRuntimeSettings | None = None, + workspace_appendix: str = "", + workspace_readiness: dict[str, Any] | None = None, + include_tools_manifest: bool = True, + prompt_detail: PromptDetail = "full", +) -> str: + """Build the system prompt with guardrails, optional skills/tools, and session scope.""" + system_prompt = str(agent_configuration.get("system_prompt") or "").strip() + guardrails = agent_configuration.get("guardrails") or [] + skills = agent_configuration.get("skills") or {} + prompt_hierarchy = agent_configuration.get("prompt_hierarchy") or [] + ui_mode = str(agent_configuration.get("graph_management_ui_mode") or "").strip() + + guardrail_lines = "\n".join(f"- {item}" for item in guardrails if str(item).strip()) + + skill_sections: list[str] = [] + if prompt_hierarchy: + hierarchy_line = " → ".join(str(item) for item in prompt_hierarchy if str(item).strip()) + if hierarchy_line: + skill_sections.append(f"Prompt hierarchy: {hierarchy_line}") + if ui_mode: + skill_sections.append(f"UI mode: {ui_mode}") + + skills_dict = dict(skills) if isinstance(skills, dict) else {} + if prompt_detail == "compact" and ui_mode == "extraction-jobs": + skill_items = sorted( + (key, value) + for key, value in skills_dict.items() + if key in _EXTRACTION_JOBS_COMPACT_SKILL_KEYS + ) + elif prompt_detail == "compact" and ui_mode == "one-off-mutations": + skill_items = sorted( + (key, value) + for key, value in skills_dict.items() + if key in _ONE_OFF_MUTATIONS_COMPACT_SKILL_KEYS + ) + elif prompt_detail == "full": + skill_items = sorted(skills_dict.items()) + else: + skill_items = [] + + for key, value in skill_items: + text = str(value).strip() + if text: + skill_sections.append(f"**{key}**: {text}") + + skills_block = "" + if skill_sections and (prompt_detail == "full" or skill_items): + skills_block = "## Skills\n\n" + "\n\n".join(skill_sections) + + tools_block = "" + if include_tools_manifest and settings is not None and settings.workload_token.strip(): + if prompt_detail == "compact": + mode_block = "" + if ui_mode == "extraction-jobs": + mode_block = f"\n\n{_EXTRACTION_JOBS_TOOLS_REFERENCE}" + elif ui_mode == "one-off-mutations": + mode_block = f"\n\n{_ONE_OFF_MUTATIONS_TOOLS_REFERENCE}" + tools_block = f"## Tools\n\n{_TOOLS_COMPACT_REFERENCE}{mode_block}" + else: + kartograph_tools = ", ".join( + f"`{name}`" + for name in ( + *KARTOGRAPH_SCHEMA_TOOL_NAMES, + *( + KARTOGRAPH_EXTRACTION_JOBS_TOOL_NAMES + if ui_mode == "extraction-jobs" + else () + ), + ) + ) + file_tools = ", ".join(f"`{name}`" for name in WORKSPACE_FILE_TOOL_NAMES) + mode_block = "" + if ui_mode == "extraction-jobs": + mode_block = f"\n\n{_EXTRACTION_JOBS_TOOLS_REFERENCE}" + elif ui_mode == "one-off-mutations": + mode_block = f"\n\n{_ONE_OFF_MUTATIONS_TOOLS_REFERENCE}" + tools_block = ( + f"{_TOOLS_QUICK_REFERENCE}{mode_block}\n\n" + f"Registered Kartograph tools: {kartograph_tools}.\n" + f"Registered workspace tools: {file_tools}." + ) + + session_block = "" + if settings is not None: + kg_id = settings.knowledge_graph_id.strip() + tenant_id = settings.tenant_id.strip() + if kg_id or tenant_id: + lines = ["## Session scope"] + if kg_id: + lines.append(f"- Knowledge graph: `{kg_id}`") + if tenant_id: + lines.append(f"- Tenant: `{tenant_id}`") + lines.append( + "- All Kartograph schema tool writes target this knowledge graph automatically." + ) + session_block = "\n".join(lines) + + readiness_block = "" + if workspace_readiness: + readiness_block = _format_workspace_readiness(workspace_readiness) + + sections = [ + section + for section in ( + system_prompt, + guardrail_lines, + skills_block, + tools_block, + readiness_block, + session_block, + workspace_appendix.strip(), + ) + if section + ] + return "\n\n".join(sections) or "You are the Graph Management Assistant." diff --git a/src/agent-runtime/kartograph_agent_runtime/executor.py b/src/agent-runtime/kartograph_agent_runtime/executor.py new file mode 100644 index 000000000..451983152 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/executor.py @@ -0,0 +1,575 @@ +"""Turn execution for sticky session chat using Claude Agent SDK or fallback mode.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +from collections.abc import AsyncIterator +from typing import Any + +from kartograph_agent_runtime.agent_prompt import build_agent_system_prompt +from kartograph_agent_runtime.settings import AgentRuntimeSettings +from kartograph_agent_runtime.thinking_stream import ( + initial_sdk_thinking_lines, + push_thinking, + replace_last_thinking, + thinking_events_from_sdk_message, +) +from kartograph_agent_runtime.tools import RuntimeTooling +from kartograph_agent_runtime.vertex import VERTEX_COMPATIBLE_EFFORT, build_claude_agent_env + +_DEFAULT_TURN_TIMEOUT_SECONDS = 1000.0 +_SDK_HEARTBEAT_SECONDS = 8.0 + + +def _build_system_prompt( + agent_configuration: dict[str, Any], + *, + settings: AgentRuntimeSettings | None = None, + workspace_appendix: str = "", + workspace_readiness: dict[str, Any] | None = None, + prompt_detail: str = "full", +) -> str: + return build_agent_system_prompt( + agent_configuration, + settings=settings, + workspace_appendix=workspace_appendix, + workspace_readiness=workspace_readiness, + prompt_detail="compact" if prompt_detail == "compact" else "full", + ) + + +def _build_workspace_prompt_appendix(settings: AgentRuntimeSettings) -> str: + import json + from pathlib import Path + + root = Path(settings.workspace_dir) + index_path = root / "sources-index.json" + if index_path.is_file(): + try: + index = json.loads(index_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + index = None + sources = index.get("sources") if isinstance(index, dict) else None + if isinstance(sources, list) and sources: + lines = [ + "## Session workspace", + f"Workspace mount: `{settings.workspace_dir}`", + ( + "Read-only: `repository-files/`. " + "Writable: entire workspace except repository snapshots — " + "see `instance_generators/PREPOPULATION_WORKFLOW.md`. " + "`instance_generators/{Label}.py` and `out/{Label}_instances.json(l)` (case-sensitive). " + "Platform: `entities_to_jsonl.py`, `relationships_to_jsonl.py`, `preview_instances.py`, " + "`scanner_common.py`. Never `/tmp`. One batch per gap via apply-from-file." + ), + ] + for source in sources[:12]: + if not isinstance(source, dict): + continue + data_source_name = str(source.get("data_source_name") or "?") + data_source_id = str(source.get("data_source_id") or "?") + entry_count = source.get("entry_count", 0) + repository_folder = str(source.get("repository_folder") or "").strip() + repository_root = str( + source.get("repository_root") + or ( + f"repository-files/{repository_folder}" + if repository_folder + else f"repository-files/{data_source_name}" + ) + ) + package_id = str(source.get("job_package_id") or "?") + lines.append( + f"- `{repository_root}`: {entry_count} file(s) " + f"(data source `{data_source_name}`, id `{data_source_id}`, " + f"JobPackage `{package_id}`)" + ) + sample_paths = source.get("sample_paths") + if isinstance(sample_paths, list): + for path in sample_paths[:6]: + if path: + lines.append(f" - `{path}`") + extension_counts = source.get("file_extension_counts") + if isinstance(extension_counts, dict) and extension_counts: + top_extensions = sorted( + extension_counts.items(), + key=lambda item: (-int(item[1]), str(item[0])), + )[:8] + summary = ", ".join(f"{ext}={count}" for ext, count in top_extensions) + lines.append(f" - extensions: {summary}") + return "\n".join(lines) + + repo_root = root / "repository-files" + if not repo_root.is_dir(): + return ( + f"## Session workspace\n" + f"Workspace mount: `{settings.workspace_dir}`\n" + "No prepared JobPackage repository files are materialized yet. " + "Prepare data sources under Graph Management → Data sources." + ) + + package_dirs = sorted(path for path in repo_root.iterdir() if path.is_dir()) + if not package_dirs: + return ( + f"## Session workspace\n" + f"Workspace mount: `{settings.workspace_dir}`\n" + "Prepared data sources exist, but repository files have not been extracted yet. " + "Re-prepare data sources under Graph Management → Data sources." + ) + + lines = [ + "## Session workspace", + f"Workspace mount: `{settings.workspace_dir}`", + ( + "Prepared repository files live under " + "`repository-files//` relative to the workspace mount " + "(one folder per data source; names are slugified data source names). " + "Use Read, Grep, and Glob tools against those paths." + ), + ] + for package_dir in package_dirs[:8]: + files = sorted(path for path in package_dir.rglob("*") if path.is_file()) + lines.append(f"- `repository-files/{package_dir.name}`: {len(files)} file(s)") + for file_path in files[:4]: + rel = file_path.relative_to(package_dir).as_posix() + lines.append(f" - `{rel}`") + return "\n".join(lines) + + +def _apply_model_env(settings: AgentRuntimeSettings) -> str: + for key, value in build_claude_agent_env(settings).items(): + os.environ[key] = value + if settings.openshell_inference_enabled(): + return "OpenShell inference (Vertex)" + if settings.vertex_enabled(): + return "Vertex AI" + if settings.anthropic_api_key.strip(): + return "Anthropic API" + return "unconfigured" + + +def _extract_sdk_reply(message: Any) -> str | None: + result = getattr(message, "result", None) + if isinstance(result, str) and result.strip(): + return result.strip() + + structured = getattr(message, "structured_output", None) + if structured is not None: + if isinstance(structured, str) and structured.strip(): + return structured.strip() + try: + return json.dumps(structured, indent=2) + except TypeError: + return str(structured) + + content = getattr(message, "content", None) + if isinstance(content, str) and content.strip(): + return content.strip() + if isinstance(content, list): + parts: list[str] = [] + for block in content: + text = getattr(block, "text", None) + if isinstance(text, str) and text.strip(): + parts.append(text) + if parts: + return "".join(parts).strip() + return None + + +def finalize_sdk_turn_reply( + *, + reply: str | None, + reply_parts: list[str], + last_result: Any | None, + notification_summaries: list[str], +) -> str | None: + """Build the best available assistant reply after an SDK turn completes.""" + if isinstance(reply, str) and reply.strip(): + return reply.strip() + + streamed = "".join(reply_parts).strip() + if streamed: + return streamed + + if last_result is not None: + extracted = _extract_sdk_reply(last_result) + if extracted: + return extracted + + if notification_summaries: + return notification_summaries[-1] + + num_turns = int(getattr(last_result, "num_turns", 0) or 0) + if num_turns > 0: + return ( + f"**Assistant completed** ({num_turns} turn(s))\n\n" + "The agent finished tool work without a final written reply. " + "Review workspace artifacts or graph mutations, or ask the assistant " + "to summarize what it changed." + ) + + return None + + +def metrics_from_sdk_result(result: Any | None) -> dict[str, Any]: + """Extract token usage and cost from a Claude Agent SDK ResultMessage.""" + if result is None: + return {} + raw_usage = getattr(result, "usage", None) + usage = raw_usage if isinstance(raw_usage, dict) else {} + total_cost = getattr(result, "total_cost_usd", None) + cost_usd = float(total_cost) if total_cost is not None else 0.0 + if not usage and cost_usd == 0.0: + return {} + return { + "input_tokens": int(usage.get("input_tokens") or 0), + "output_tokens": int(usage.get("output_tokens") or 0), + "cache_read_tokens": int(usage.get("cache_read_input_tokens") or 0), + "cache_creation_tokens": int(usage.get("cache_creation_input_tokens") or 0), + "cost_usd": cost_usd, + } + + +def _build_sdk_env(settings: AgentRuntimeSettings) -> dict[str, str]: + env = build_claude_agent_env(settings) + if settings.gcloud_config_dir.strip(): + env.setdefault("CLOUDSDK_CONFIG", settings.gcloud_config_dir.strip()) + if settings.google_application_credentials.strip(): + env.setdefault( + "GOOGLE_APPLICATION_CREDENTIALS", + settings.google_application_credentials.strip(), + ) + env.setdefault("HOME", settings.home_dir.strip() or "/tmp") + env.setdefault("API_TIMEOUT_MS", "120000") + env.setdefault("CLAUDE_CODE_MAX_RETRIES", "2") + env.setdefault("CLAUDE_ASYNC_AGENT_STALL_TIMEOUT_MS", "120000") + return env + + +def _timeout_error_message( + *, + settings: AgentRuntimeSettings, + auth_mode: str, + turn_timeout_seconds: float, +) -> str: + parts = [ + f"Claude Agent SDK did not complete within {int(turn_timeout_seconds)}s.", + ] + if auth_mode == "Vertex AI": + creds_path = settings.google_application_credentials.strip() + creds_present = bool(creds_path) + parts.append( + "Vertex AI " + f"project={settings.vertex_project_id.strip() or '(missing)'}, " + f"region={settings.vertex_region.strip() or '(missing)'}, " + f"ADC={'configured' if creds_present else 'missing'}." + ) + if creds_present: + from pathlib import Path + + creds_readable = Path(creds_path).is_file() + parts.append( + f"Credentials file {'readable' if creds_readable else 'not found'} at {creds_path}." + ) + else: + parts.append( + "Direct Anthropic API " + f"{'configured' if settings.anthropic_api_key.strip() else 'missing ANTHROPIC_API_KEY'}." + ) + parts.append( + "The model may still be running in the container — check sticky container logs " + "for Vertex auth or quota errors." + ) + return " ".join(parts) + + +async def _iter_sdk_messages_with_heartbeat( + sdk_iter: AsyncIterator[Any], + *, + heartbeat_seconds: float, +) -> AsyncIterator[Any | None]: + """Yield SDK messages, or ``None`` when a heartbeat tick is due. + + Unlike ``asyncio.wait_for`` on ``__anext__()``, this never cancels a pending + SDK read — cancelling mid-stream drops messages and prevents ResultMessage delivery. + """ + pending = asyncio.create_task(sdk_iter.__anext__()) + try: + while True: + done, _ = await asyncio.wait( + {pending}, + timeout=heartbeat_seconds, + return_when=asyncio.FIRST_COMPLETED, + ) + if pending in done: + try: + yield pending.result() + except StopAsyncIteration: + return + pending = asyncio.create_task(sdk_iter.__anext__()) + else: + yield None + finally: + if not pending.done(): + pending.cancel() + with contextlib.suppress(asyncio.CancelledError, StopAsyncIteration): + await pending + + +def _tooling_settings( + settings: AgentRuntimeSettings, + workload_token: str | None = None, +) -> AgentRuntimeSettings: + token = (workload_token or "").strip() + if not token: + return settings + return settings.model_copy(update={"workload_token": token}) + + +async def stream_turn_events( + *, + settings: AgentRuntimeSettings, + message: str, + ui_mode: str, + agent_configuration: dict[str, Any], + message_history: list[dict[str, Any]], + turn_timeout_seconds: float = _DEFAULT_TURN_TIMEOUT_SECONDS, + workload_token: str | None = None, +) -> AsyncIterator[dict[str, Any]]: + effective_settings = _tooling_settings(settings, workload_token) + auth_mode = _apply_model_env(effective_settings) + yield { + "type": "thinking", + "recent": [ + "Starting Claude Agent SDK runtime…", + f"Model backend: {auth_mode}", + f"Applying {ui_mode} skill overlay", + ], + } + + if effective_settings.model_configured(): + async for event in _stream_with_claude_sdk( + settings=effective_settings, + message=message, + ui_mode=ui_mode, + agent_configuration=agent_configuration, + message_history=message_history, + auth_mode=auth_mode, + turn_timeout_seconds=turn_timeout_seconds, + ): + yield event + return + + tooling = RuntimeTooling(settings=effective_settings) + skill_keys = ", ".join(sorted(agent_configuration.get("skills", {}).keys())[:4]) or "default" + reply = ( + f"**Graph Management Assistant ({ui_mode})**\n\n" + f"I received your message with skills: {skill_keys}.\n\n" + f"> {message.strip()}\n\n" + "Configure Vertex AI (`CLAUDE_CODE_USE_VERTEX=1`, `ANTHROPIC_VERTEX_PROJECT_ID`, " + "`CLOUD_ML_REGION`) or `ANTHROPIC_API_KEY` to enable live model execution. " + "Graph and mutation tools are wired via " + f"`{effective_settings.api_base_url}` using the injected workload token." + ) + if message.lower().startswith("search graph:"): + slug = message.split(":", 1)[1].strip() + try: + graph_result = await tooling.search_graph_by_slug(slug=slug) + reply += f"\n\nGraph search returned {graph_result.get('count', 0)} node(s)." + except Exception as exc: # noqa: BLE001 + reply += f"\n\nGraph search failed: {exc}" + yield {"type": "done", "ok": True, "reply": reply} + + +async def _stream_with_claude_sdk( + *, + settings: AgentRuntimeSettings, + message: str, + ui_mode: str, + agent_configuration: dict[str, Any], + message_history: list[dict[str, Any]], + auth_mode: str, + turn_timeout_seconds: float, +) -> AsyncIterator[dict[str, Any]]: + from claude_agent_sdk import ClaudeAgentOptions, query + from claude_agent_sdk.types import ResultMessage, TaskNotificationMessage + + workspace_dir = settings.workspace_dir.strip() or "/workspace" + tooling = RuntimeTooling(settings=settings) + workspace_readiness: dict[str, Any] | None = None + if settings.workload_token.strip(): + try: + workspace_readiness = await tooling.get_workspace_readiness() + except Exception: # noqa: BLE001 + workspace_readiness = None + + prior_turns = sum( + 1 for entry in message_history if isinstance(entry, dict) and entry.get("role") == "user" + ) + prompt_detail = "full" if prior_turns <= 1 else "compact" + + system_prompt = _build_system_prompt( + agent_configuration, + settings=settings, + workspace_appendix=_build_workspace_prompt_appendix(settings), + workspace_readiness=workspace_readiness, + prompt_detail=prompt_detail, + ) + history_lines = [ + f"{entry.get('role', 'unknown')}: {entry.get('content', '')}" + for entry in message_history[-6:] + if isinstance(entry, dict) + ] + prompt = message + if history_lines: + prompt = "Recent conversation:\n" + "\n".join(history_lines) + f"\n\nUser: {message}" + + recent = initial_sdk_thinking_lines(auth_mode=auth_mode, ui_mode=ui_mode) + yield {"type": "thinking", "recent": list(recent)} + + sdk_env = _build_sdk_env(settings) + options_kwargs: dict[str, Any] = {} + if settings.workload_token.strip(): + from kartograph_agent_runtime.schema_tools import ( + GMA_ALLOWED_TOOL_NAMES, + build_kartograph_schema_mcp_server, + ) + + options_kwargs["mcp_servers"] = { + "kartograph": build_kartograph_schema_mcp_server(tooling), + } + options_kwargs["allowed_tools"] = list(GMA_ALLOWED_TOOL_NAMES) + if settings.openshell_inference_enabled(): + options_kwargs["extra_args"] = {"bare": None} + options_kwargs["effort"] = VERTEX_COMPATIBLE_EFFORT + elif settings.vertex_enabled(): + options_kwargs["effort"] = VERTEX_COMPATIBLE_EFFORT + options = ClaudeAgentOptions( + system_prompt=system_prompt, + env=sdk_env, + permission_mode="bypassPermissions", + max_turns=settings.max_turns, + setting_sources=[], + cwd=workspace_dir, + add_dirs=[workspace_dir], + **options_kwargs, + ) + + reply: str | None = None + reply_parts: list[str] = [] + notification_summaries: list[str] = [] + last_result: ResultMessage | None = None + last_compose_at = 0 + elapsed_seconds = 0 + try: + async with asyncio.timeout(turn_timeout_seconds): + sdk_iter = query(prompt=prompt, options=options).__aiter__() + async for sdk_message in _iter_sdk_messages_with_heartbeat( + sdk_iter, + heartbeat_seconds=_SDK_HEARTBEAT_SECONDS, + ): + if sdk_message is None: + elapsed_seconds += int(_SDK_HEARTBEAT_SECONDS) + heartbeat = replace_last_thinking( + recent, + f"Waiting for model response… ({elapsed_seconds}s)", + prefix="Waiting for model response", + ) + if heartbeat: + yield heartbeat + await asyncio.sleep(0) + continue + + thinking_events, last_compose_at = thinking_events_from_sdk_message( + sdk_message, + recent=recent, + reply_parts=reply_parts, + last_compose_at=last_compose_at, + ) + for event in thinking_events: + yield event + await asyncio.sleep(0) + + if isinstance(sdk_message, TaskNotificationMessage): + summary = str(sdk_message.summary or "").strip() + if summary: + notification_summaries.append(summary) + + if isinstance(sdk_message, ResultMessage): + if sdk_message.is_error: + error_text = str(sdk_message.result or "").strip() + if not error_text and sdk_message.errors: + error_text = "; ".join(str(item) for item in sdk_message.errors) + if error_text: + error_thinking = push_thinking(recent, f"Error · {error_text}") + if error_thinking: + yield error_thinking + await asyncio.sleep(0) + yield { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_SDK_ERROR", + "message": error_text or "Claude Agent SDK returned an error.", + }, + } + return + last_result = sdk_message + + extracted = _extract_sdk_reply(sdk_message) + if extracted: + reply = extracted + elif reply_parts: + reply = "".join(reply_parts).strip() or None + except TimeoutError: + yield { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_TURN_TIMEOUT", + "message": _timeout_error_message( + settings=settings, + auth_mode=auth_mode, + turn_timeout_seconds=turn_timeout_seconds, + ), + }, + } + return + except Exception as exc: # noqa: BLE001 + yield { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_TURN_FAILED", + "message": str(exc), + }, + } + return + + reply = finalize_sdk_turn_reply( + reply=reply, + reply_parts=reply_parts, + last_result=last_result, + notification_summaries=notification_summaries, + ) + if not reply: + yield { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_NO_TEXTUAL_REPLY", + "message": ( + "The Graph Management Assistant finished without a reply. " + "Check sticky container logs for SDK output, then retry." + ), + }, + } + return + done_payload: dict[str, Any] = {"type": "done", "ok": True, "reply": reply} + usage_metrics = metrics_from_sdk_result(last_result) + if usage_metrics: + done_payload["usage"] = usage_metrics + yield done_payload diff --git a/src/agent-runtime/kartograph_agent_runtime/extraction_jobs_tools.py b/src/agent-runtime/kartograph_agent_runtime/extraction_jobs_tools.py new file mode 100644 index 000000000..df2c7a575 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/extraction_jobs_tools.py @@ -0,0 +1,136 @@ +"""MCP tool handlers for extraction job set configuration.""" + +from __future__ import annotations + +from typing import Any + +from claude_agent_sdk import tool + +from kartograph_agent_runtime.tools import RuntimeTooling + +KARTOGRAPH_EXTRACTION_JOBS_TOOL_NAMES = ( + "kartograph_get_extraction_jobs_config", + "kartograph_save_extraction_jobs_config", + "kartograph_get_extraction_jobs_plan_summary", + "kartograph_get_extraction_jobs_status", +) + + +def append_extraction_jobs_tools(*, tooling: RuntimeTooling, tools: list[Any]) -> None: + """Register extraction job configuration tools on the Kartograph MCP server.""" + + @tool( + "kartograph_get_extraction_jobs_config", + ( + "Read saved extraction job sets for this knowledge graph, including live " + "entity type instance counts and relationship_authoring_by_entity_type " + "(owned line prefixes and IGNORE lines per entity type). Call before proposing " + "or saving changes." + ), + {}, + ) + async def get_extraction_jobs_config(_args: dict[str, Any]) -> dict[str, Any]: + try: + return RuntimeTooling.format_tool_result( + await tooling.get_extraction_jobs_config(), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [ + {"type": "text", "text": f"Failed to read extraction jobs config: {exc}"} + ], + "is_error": True, + } + + @tool( + "kartograph_save_extraction_jobs_config", + ( + "Save extraction job sets for this knowledge graph and regenerate pending jobs. " + "Pass the full job_sets array (read existing config first and merge edits). " + "Each job set needs: name, strategy (by_instances or by_files), description, " + "entity_type + instances_per_job for by_instances, or file_patterns + files_per_job " + "for by_files. For by_instances, description must match per_instance_description_authoring: " + "opening capture-everything paragraph, Properties section listing each property, then " + "one '{EntityType} -> {rel} -> {CounterpartType}:' line per owned relationship, " + "plus an 'Ignore these relationships:' section with explicit IGNORE lines for edges " + "where the counterpart type has more instances (e.g. IGNORE Adapter -> " + "verifies_inverse -> ComponentTest when ComponentTest count >> Adapter count)." + ), + { + "version": str, + "job_sets": list, + }, + ) + async def save_extraction_jobs_config(args: dict[str, Any]) -> dict[str, Any]: + job_sets = args.get("job_sets") + if not isinstance(job_sets, list): + return { + "content": [{"type": "text", "text": "job_sets must be a list of job set objects."}], + "is_error": True, + } + payload: dict[str, Any] = { + "version": str(args.get("version") or "1.0"), + "job_sets": job_sets, + } + try: + return RuntimeTooling.format_tool_result( + await tooling.save_extraction_jobs_config(payload=payload), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [ + {"type": "text", "text": f"Failed to save extraction jobs config: {exc}"} + ], + "is_error": True, + } + + @tool( + "kartograph_get_extraction_jobs_plan_summary", + ( + "Return projected pending job counts per configured job set based on live " + "graph instances and file catalog matches." + ), + {}, + ) + async def get_extraction_jobs_plan_summary(_args: dict[str, Any]) -> dict[str, Any]: + try: + return RuntimeTooling.format_tool_result( + await tooling.get_extraction_jobs_plan_summary(), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [ + {"type": "text", "text": f"Failed to load extraction jobs plan summary: {exc}"} + ], + "is_error": True, + } + + @tool( + "kartograph_get_extraction_jobs_status", + ( + "Return materialized extraction job queue metrics: counts by status, recent jobs, " + "and active workers." + ), + {}, + ) + async def get_extraction_jobs_status(_args: dict[str, Any]) -> dict[str, Any]: + try: + return RuntimeTooling.format_tool_result( + await tooling.get_extraction_jobs_status(), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [ + {"type": "text", "text": f"Failed to load extraction jobs status: {exc}"} + ], + "is_error": True, + } + + tools.extend( + [ + get_extraction_jobs_config, + save_extraction_jobs_config, + get_extraction_jobs_plan_summary, + get_extraction_jobs_status, + ] + ) diff --git a/src/agent-runtime/kartograph_agent_runtime/runtime_auth.py b/src/agent-runtime/kartograph_agent_runtime/runtime_auth.py new file mode 100644 index 000000000..9825d864a --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/runtime_auth.py @@ -0,0 +1,14 @@ +"""Runtime HTTP auth helpers for sticky session agent containers.""" + +from __future__ import annotations + +import secrets + +RUNTIME_AUTH_HEADER = "X-Kartograph-Runtime-Auth" + + +def runtime_auth_matches(*, expected: str, provided: str) -> bool: + """Constant-time comparison for runtime auth header values.""" + if not expected or not provided: + return False + return secrets.compare_digest(expected.strip(), provided.strip()) diff --git a/src/agent-runtime/kartograph_agent_runtime/schema_tools.py b/src/agent-runtime/kartograph_agent_runtime/schema_tools.py new file mode 100644 index 000000000..97dcb61bd --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/schema_tools.py @@ -0,0 +1,362 @@ +"""In-process MCP tools for Kartograph schema authoring.""" + +from __future__ import annotations + +from typing import Any + +from claude_agent_sdk import create_sdk_mcp_server, tool + +from kartograph_agent_runtime.extraction_jobs_tools import ( + KARTOGRAPH_EXTRACTION_JOBS_TOOL_NAMES, + append_extraction_jobs_tools, +) +from kartograph_agent_runtime.tools import RuntimeTooling + +WORKSPACE_FILE_TOOL_NAMES = ("Read", "Write", "Edit", "Grep", "Glob", "Bash") + +LIST_INSTANCES_BY_TYPE_TOOL_DESCRIPTION = ( + "List entity instances for one type with pagination. Returns mutation-ready " + "`id`, `slug`, and `properties` per node — paginate with offset until total. " + "Primary bulk query tool for DELETE JSONL; avoid per-slug search loops." +) + +KARTOGRAPH_SCHEMA_TOOL_NAMES = ( + "kartograph_get_schema_authoring_guide", + "kartograph_get_workspace_readiness", + "kartograph_get_schema_ontology", + "kartograph_save_schema_ontology", + "kartograph_validate_graph_mutations", + "kartograph_apply_graph_mutations", + "kartograph_validate_graph_mutations_from_file", + "kartograph_apply_graph_mutations_from_file", + "kartograph_list_instances_by_type", + "kartograph_list_relationship_instances", + "kartograph_search_graph_by_slug", + "kartograph_check_graph_slugs", +) + +GMA_ALLOWED_TOOL_NAMES = ( + KARTOGRAPH_SCHEMA_TOOL_NAMES + KARTOGRAPH_EXTRACTION_JOBS_TOOL_NAMES + WORKSPACE_FILE_TOOL_NAMES +) + + +def build_kartograph_schema_mcp_server(tooling: RuntimeTooling): + """Register Kartograph schema tools on an SDK MCP server.""" + + @tool( + "kartograph_get_schema_authoring_guide", + "Return instructions for authoring entity types, relationship types, and instances in Kartograph.", + {}, + ) + async def get_schema_authoring_guide(_args: dict[str, Any]) -> dict[str, Any]: + try: + payload = await tooling.get_schema_authoring_guide() + guide = str(payload.get("guide") or "") + return {"content": [{"type": "text", "text": guide}]} + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to load schema guide: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_get_workspace_readiness", + "Return bootstrap readiness: prepopulated gaps, live instance counts, and blocking reasons.", + {}, + ) + async def get_workspace_readiness(_args: dict[str, Any]) -> dict[str, Any]: + try: + return RuntimeTooling.format_tool_result(await tooling.get_workspace_readiness()) + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to load workspace readiness: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_get_schema_ontology", + "Read the current canonical ontology (node_types and edge_types) for this knowledge graph.", + {}, + ) + async def get_schema_ontology(_args: dict[str, Any]) -> dict[str, Any]: + try: + return RuntimeTooling.format_tool_result(await tooling.get_schema_ontology()) + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to read ontology: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_save_schema_ontology", + "Replace the canonical ontology. Pass full node_types and edge_types arrays.", + { + "node_types": list, + "edge_types": list, + "approved_at": str, + }, + ) + async def save_schema_ontology(args: dict[str, Any]) -> dict[str, Any]: + ontology = { + "node_types": args.get("node_types") or [], + "edge_types": args.get("edge_types") or [], + } + approved_at = args.get("approved_at") + if isinstance(approved_at, str) and approved_at.strip(): + ontology["approved_at"] = approved_at.strip() + try: + return RuntimeTooling.format_tool_result( + await tooling.save_schema_ontology(ontology=ontology), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to save ontology: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_validate_graph_mutations", + "Dry-run: validate JSONL mutations without writing (CREATE/UPDATE/DELETE).", + {"jsonl": str}, + ) + async def validate_graph_mutations(args: dict[str, Any]) -> dict[str, Any]: + jsonl = str(args.get("jsonl") or "").strip() + if not jsonl: + return { + "content": [{"type": "text", "text": "jsonl must not be empty."}], + "is_error": True, + } + try: + return RuntimeTooling.format_tool_result( + await tooling.validate_graph_mutations(jsonl=jsonl), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to validate mutations: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_apply_graph_mutations", + "Apply JSONL mutation lines (CREATE, UPDATE, DELETE). CREATE fails on duplicates; UPDATE/DELETE require existing ids.", + {"jsonl": str}, + ) + async def apply_graph_mutations(args: dict[str, Any]) -> dict[str, Any]: + jsonl = str(args.get("jsonl") or "").strip() + if not jsonl: + return { + "content": [{"type": "text", "text": "jsonl must not be empty."}], + "is_error": True, + } + try: + return RuntimeTooling.format_tool_result( + await tooling.apply_graph_mutations(jsonl=jsonl), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to apply mutations: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_validate_graph_mutations_from_file", + "Dry-run validate a .jsonl file under the workspace (path relative to session root).", + {"path": str}, + ) + async def validate_graph_mutations_from_file(args: dict[str, Any]) -> dict[str, Any]: + path = str(args.get("path") or "").strip() + if not path: + return { + "content": [{"type": "text", "text": "path must not be empty."}], + "is_error": True, + } + try: + return RuntimeTooling.format_tool_result( + await tooling.validate_graph_mutations_from_file(path=path), + ) + except ValueError as exc: + return { + "content": [{"type": "text", "text": str(exc)}], + "is_error": True, + } + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to validate file: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_apply_graph_mutations_from_file", + "Apply a workspace .jsonl file in one call (CREATE/UPDATE/DELETE). Apply pre-validates.", + {"path": str}, + ) + async def apply_graph_mutations_from_file(args: dict[str, Any]) -> dict[str, Any]: + path = str(args.get("path") or "").strip() + if not path: + return { + "content": [{"type": "text", "text": "path must not be empty."}], + "is_error": True, + } + try: + return RuntimeTooling.format_tool_result( + await tooling.apply_graph_mutations_from_file(path=path), + ) + except ValueError as exc: + return { + "content": [{"type": "text", "text": str(exc)}], + "is_error": True, + } + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to apply file: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_list_instances_by_type", + LIST_INSTANCES_BY_TYPE_TOOL_DESCRIPTION, + {"entity_type": str, "limit": int, "offset": int}, + ) + async def list_instances_by_type(args: dict[str, Any]) -> dict[str, Any]: + entity_type = str(args.get("entity_type") or "").strip() + if not entity_type: + return { + "content": [{"type": "text", "text": "entity_type must not be empty."}], + "is_error": True, + } + limit = args.get("limit", 100) + offset = args.get("offset", 0) + try: + return RuntimeTooling.format_tool_result( + await tooling.list_instances_by_type( + entity_type=entity_type, + limit=int(limit) if isinstance(limit, int) else 100, + offset=int(offset) if isinstance(offset, int) else 0, + ), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to list instances: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_list_relationship_instances", + "List relationship instances with source/target slugs and IDs for edge prepopulation.", + { + "relationship_type": str, + "source_entity_type": str, + "target_entity_type": str, + "limit": int, + "offset": int, + }, + ) + async def list_relationship_instances(args: dict[str, Any]) -> dict[str, Any]: + relationship_type = str(args.get("relationship_type") or "").strip() + if not relationship_type: + return { + "content": [{"type": "text", "text": "relationship_type must not be empty."}], + "is_error": True, + } + source_entity_type = args.get("source_entity_type") + target_entity_type = args.get("target_entity_type") + limit = args.get("limit", 100) + offset = args.get("offset", 0) + try: + return RuntimeTooling.format_tool_result( + await tooling.list_relationship_instances( + relationship_type=relationship_type, + source_entity_type=str(source_entity_type).strip() + if source_entity_type + else None, + target_entity_type=str(target_entity_type).strip() + if target_entity_type + else None, + limit=int(limit) if isinstance(limit, int) else 100, + offset=int(offset) if isinstance(offset, int) else 0, + ), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Failed to list relationships: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_check_graph_slugs", + "Check which slugs already exist for one entity type (before bulk CREATE).", + {"entity_type": str, "slugs": list}, + ) + async def check_graph_slugs(args: dict[str, Any]) -> dict[str, Any]: + entity_type = str(args.get("entity_type") or "").strip() + slugs = args.get("slugs") or [] + if not entity_type: + return { + "content": [{"type": "text", "text": "entity_type must not be empty."}], + "is_error": True, + } + if not isinstance(slugs, list) or not slugs: + return { + "content": [{"type": "text", "text": "slugs must be a non-empty list."}], + "is_error": True, + } + try: + return RuntimeTooling.format_tool_result( + await tooling.check_graph_slugs( + entity_type=entity_type, + slugs=[str(slug).strip() for slug in slugs if str(slug).strip()], + ), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Slug check failed: {exc}"}], + "is_error": True, + } + + @tool( + "kartograph_search_graph_by_slug", + "Search existing graph nodes by slug within the active knowledge graph.", + {"slug": str, "entity_type": str}, + ) + async def search_graph_by_slug(args: dict[str, Any]) -> dict[str, Any]: + slug = str(args.get("slug") or "").strip() + if not slug: + return { + "content": [{"type": "text", "text": "slug must not be empty."}], + "is_error": True, + } + entity_type = args.get("entity_type") + try: + return RuntimeTooling.format_tool_result( + await tooling.search_graph_by_slug( + slug=slug, + entity_type=str(entity_type).strip() if entity_type else None, + ), + ) + except Exception as exc: # noqa: BLE001 + return { + "content": [{"type": "text", "text": f"Graph search failed: {exc}"}], + "is_error": True, + } + + mcp_tools: list[Any] = [ + get_schema_authoring_guide, + get_workspace_readiness, + get_schema_ontology, + save_schema_ontology, + validate_graph_mutations, + apply_graph_mutations, + validate_graph_mutations_from_file, + apply_graph_mutations_from_file, + list_instances_by_type, + list_relationship_instances, + search_graph_by_slug, + check_graph_slugs, + ] + append_extraction_jobs_tools(tooling=tooling, tools=mcp_tools) + + return create_sdk_mcp_server( + name="kartograph", + version="1.0.0", + tools=mcp_tools, + ) diff --git a/src/agent-runtime/kartograph_agent_runtime/server.py b/src/agent-runtime/kartograph_agent_runtime/server.py new file mode 100644 index 000000000..ae2180445 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/server.py @@ -0,0 +1,129 @@ +"""HTTP server for sticky session agent runtime.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import AsyncIterator +from typing import Any + +from pathlib import Path + +from fastapi import FastAPI, Header, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel, Field + +from kartograph_agent_runtime.executor import stream_turn_events +from kartograph_agent_runtime.settings import AgentRuntimeSettings +from kartograph_agent_runtime.runtime_auth import runtime_auth_matches, RUNTIME_AUTH_HEADER + +logger = logging.getLogger(__name__) + +app = FastAPI(title="Kartograph Agent Runtime", version="0.1.0") +settings = AgentRuntimeSettings() + + +class TurnRequest(BaseModel): + message: str = Field(min_length=1) + ui_mode: str = Field(default="initial-schema-design") + agent_configuration: dict[str, Any] = Field(default_factory=dict) + message_history: list[dict[str, Any]] = Field(default_factory=list) + workload_token: str | None = Field( + default=None, + description="Fresh scoped JWT for Kartograph schema/mutation tools (preferred over container env).", + ) + + +def _workspace_ready() -> bool: + marker = Path(settings.workspace_dir) / "knowledge-graph-id" + return marker.is_file() + + +@app.get("/health") +async def health(): + if not _workspace_ready(): + return JSONResponse( + status_code=503, + content={ + "status": "workspace_unavailable", + "session_id": settings.session_id, + }, + ) + return {"status": "ok", "session_id": settings.session_id} + + +def _require_runtime_auth(runtime_auth: str | None) -> None: + expected = settings.runtime_auth_token.strip() + if not expected: + return + if not runtime_auth_matches(expected=expected, provided=runtime_auth or ""): + raise HTTPException( + status_code=401, + detail={ + "code": "RUNTIME_AUTH_REQUIRED", + "message": "Missing or invalid runtime auth token.", + }, + ) + + +@app.post("/v1/turn") +async def stream_turn( + request: TurnRequest, + x_kartograph_runtime_auth: str | None = Header(default=None, alias=RUNTIME_AUTH_HEADER), +) -> StreamingResponse: + _require_runtime_auth(x_kartograph_runtime_auth) + logger.info( + "agent_runtime_turn_started session_id=%s ui_mode=%s message_len=%s", + settings.session_id, + request.ui_mode, + len(request.message), + ) + + async def event_stream() -> AsyncIterator[str]: + try: + async for event in stream_turn_events( + settings=settings, + message=request.message, + ui_mode=request.ui_mode, + agent_configuration=request.agent_configuration, + message_history=request.message_history, + turn_timeout_seconds=settings.turn_timeout_seconds, + workload_token=request.workload_token, + ): + if event.get("type") == "done": + logger.info( + "agent_runtime_turn_finished session_id=%s ok=%s", + settings.session_id, + event.get("ok"), + ) + yield json.dumps(event) + "\n" + await asyncio.sleep(0) + except Exception: + logger.exception( + "agent_runtime_turn_failed session_id=%s", + settings.session_id, + ) + yield ( + json.dumps( + { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_RUNTIME_INTERNAL_ERROR", + "message": "Agent runtime failed while processing the turn.", + }, + } + ) + + "\n" + ) + + return StreamingResponse( + event_stream(), + media_type="application/x-ndjson", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/src/agent-runtime/kartograph_agent_runtime/settings.py b/src/agent-runtime/kartograph_agent_runtime/settings.py new file mode 100644 index 000000000..41f4c1b42 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/settings.py @@ -0,0 +1,50 @@ +"""Agent runtime settings loaded from container environment.""" + +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from kartograph_agent_runtime.vertex import vertex_enabled_from_env + + +class AgentRuntimeSettings(BaseSettings): + """Runtime configuration for sticky session agent containers.""" + + model_config = SettingsConfigDict(extra="ignore") + + host: str = Field(default="0.0.0.0") + port: int = Field(default=8787) + api_base_url: str = Field(default="http://api:8000", alias="KARTOGRAPH_API_BASE_URL") + workload_token: str = Field(default="", alias="KARTOGRAPH_WORKLOAD_TOKEN") + runtime_auth_token: str = Field(default="", alias="KARTOGRAPH_RUNTIME_AUTH_TOKEN") + tenant_id: str = Field(default="", alias="KARTOGRAPH_TENANT_ID") + knowledge_graph_id: str = Field(default="", alias="KARTOGRAPH_KNOWLEDGE_GRAPH_ID") + session_id: str = Field(default="", alias="KARTOGRAPH_SESSION_ID") + workspace_dir: str = Field(default="/workspace", alias="KARTOGRAPH_WORKSPACE_DIR") + anthropic_api_key: str = Field(default="", alias="ANTHROPIC_API_KEY") + anthropic_base_url: str = Field(default="", alias="ANTHROPIC_BASE_URL") + claude_code_disable_experimental_betas: str = Field( + default="", + alias="CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", + ) + vertex_project_id: str = Field(default="", alias="ANTHROPIC_VERTEX_PROJECT_ID") + vertex_region: str = Field(default="us-east5", alias="CLOUD_ML_REGION") + gcloud_config_dir: str = Field(default="", alias="CLOUDSDK_CONFIG") + google_application_credentials: str = Field(default="", alias="GOOGLE_APPLICATION_CREDENTIALS") + home_dir: str = Field(default="/tmp", alias="HOME") + turn_timeout_seconds: float = Field(default=1000.0, ge=30.0, le=3600.0, alias="KARTOGRAPH_AGENT_TURN_TIMEOUT_SECONDS") + max_turns: int = Field(default=500, ge=1, le=1000, alias="KARTOGRAPH_AGENT_MAX_TURNS") + + def vertex_enabled(self) -> bool: + return vertex_enabled_from_env() + + def openshell_inference_enabled(self) -> bool: + return self.anthropic_base_url.strip().rstrip("/") == "https://inference.local" + + def model_configured(self) -> bool: + if self.openshell_inference_enabled(): + return True + if self.vertex_enabled(): + return bool(self.vertex_project_id.strip()) + return bool(self.anthropic_api_key.strip()) diff --git a/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py b/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py new file mode 100644 index 000000000..74fc53561 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py @@ -0,0 +1,358 @@ +"""Rolling thinking-line panel updates for NDJSON chat streams.""" + +from __future__ import annotations + +from typing import Any + +# Rolling window surfaced to the Graph Management Assistant UI (last N thoughts). +_MAX_THINKING_LINES = 3 + + +def normalize_activity_line(text: str) -> str: + line = " ".join(text.split()) + if len(line) > 120: + return line[:117] + "…" + return line + + +def push_thinking(recent: list[str], line: str) -> dict[str, Any] | None: + normalized = normalize_activity_line(line) + if not normalized: + return None + if recent and recent[-1] == normalized: + return None + recent.append(normalized) + if len(recent) > _MAX_THINKING_LINES: + recent[:] = recent[-_MAX_THINKING_LINES:] + return {"type": "thinking", "recent": list(recent)} + + +def replace_last_thinking( + recent: list[str], + line: str, + *, + prefix: str | None = None, +) -> dict[str, Any] | None: + """Replace the last matching (or final) thinking line — used for heartbeats.""" + normalized = normalize_activity_line(line) + if not normalized: + return None + if prefix: + for index in range(len(recent) - 1, -1, -1): + if str(recent[index]).startswith(prefix): + if recent[index] == normalized: + return None + recent[index] = normalized + return {"type": "thinking", "recent": list(recent)} + if recent: + if recent[-1] == normalized: + return None + recent[-1] = normalized + else: + recent.append(normalized) + return {"type": "thinking", "recent": list(recent)} + + +def _compose_reply_line(preview_tail: str) -> str: + preview_tail = normalize_activity_line(preview_tail.replace("\n", " ")) + if not preview_tail: + return "Composing reply…" + lowered = preview_tail.lower() + noisy_prefixes = ( + "need.", + "need ", + "let me", + "now let me", + "i'll ", + "i will ", + "creating task", + "first, let", + ) + if any(lowered.startswith(prefix) for prefix in noisy_prefixes): + return "Composing reply…" + if len(preview_tail) < 12: + return "Composing reply…" + return f"Composing reply · {preview_tail}" + + +def update_composing_line(recent: list[str], preview_tail: str) -> dict[str, Any] | None: + line = _compose_reply_line(preview_tail) + prefix = "Composing reply" + if recent and str(recent[-1]).startswith(prefix): + recent[-1] = line + return {"type": "thinking", "recent": list(recent)} + return push_thinking(recent, line) + + +def _tool_use_line(name: str, tool_input: dict[str, Any]) -> str: + if name == "Read": + path = tool_input.get("file_path") or tool_input.get("path") or "" + return f"Reading {path}" if path else "Reading file…" + if name in {"Write", "Edit"}: + path = tool_input.get("file_path") or tool_input.get("path") or "" + verb = "Writing" if name == "Write" else "Editing" + return f"{verb} {path}" if path else f"{verb} file…" + if name == "Grep": + pattern = tool_input.get("pattern") or "" + return f"Searching for {pattern}" if pattern else "Searching repository…" + if name == "Glob": + pattern = tool_input.get("pattern") or "" + return f"Listing files {pattern}" if pattern else "Listing files…" + if name == "Bash": + command = tool_input.get("command") or "" + return f"Running {command}" if command else "Running shell command…" + if name.startswith("kartograph_"): + readable = name.removeprefix("kartograph_").replace("_", " ") + return f"Schema tool · {readable}" + return f"Running {name}…" + + +def _stream_event_line(event: dict[str, Any]) -> str | None: + event_type = event.get("type") + if event_type == "content_block_start": + block = event.get("content_block") or {} + block_type = block.get("type") + if block_type == "tool_use": + name = block.get("name") or "tool" + return f"Running {name}…" + if block_type == "thinking": + return "Reasoning…" + if block_type == "text": + return "Composing reply…" + if event_type == "content_block_delta": + delta = event.get("delta") or {} + if delta.get("type") == "thinking_delta": + thinking = str(delta.get("thinking") or "").strip() + if thinking: + return f"Reasoning · {normalize_activity_line(thinking)}" + return None + + +def _thinking_events_from_stream_event( + event: dict[str, Any], + *, + recent: list[str], + reply_parts: list[str], + last_compose_at: int, + compose_step: int, +) -> tuple[list[dict[str, Any]], int]: + events: list[dict[str, Any]] = [] + if event.get("type") == "content_block_delta": + delta = event.get("delta") or {} + if delta.get("type") == "text_delta": + text = str(delta.get("text") or "") + if text: + reply_parts.append(text) + blob = "".join(reply_parts) + if len(blob.strip()) and len(blob) - last_compose_at >= compose_step: + tail = blob[-88:].replace("\n", " ").strip() + compose_event = update_composing_line(recent, tail) + if compose_event: + events.append(compose_event) + last_compose_at = len(blob) + return events, last_compose_at + + line = _stream_event_line(event) + if line: + compose_event = push_thinking(recent, line) + if compose_event: + events.append(compose_event) + return events, last_compose_at + + +def _append_task_progress_events( + events: list[dict[str, Any]], + recent: list[str], + *, + description: str, + last_tool_name: str | None, + started: bool, +) -> None: + progress_description = description.strip() + last_tool = str(last_tool_name or "").strip() + if progress_description: + prefix = "Task started · " if started else "" + event = push_thinking(recent, f"{prefix}{progress_description}".strip()) + if event: + events.append(event) + if last_tool: + event = push_thinking(recent, f"Running {last_tool}…") + if event: + events.append(event) + + +def _thinking_events_from_assistant_content( + content: list[Any], + *, + recent: list[str], + reply_parts: list[str], + last_compose_at: int, + compose_step: int, +) -> tuple[list[dict[str, Any]], int]: + from claude_agent_sdk.types import TextBlock, ThinkingBlock, ToolUseBlock + + events: list[dict[str, Any]] = [] + for block in content: + if isinstance(block, ThinkingBlock): + thinking = normalize_activity_line(block.thinking or "") + if thinking: + event = push_thinking(recent, f"Reasoning · {thinking}") + if event: + events.append(event) + elif isinstance(block, ToolUseBlock): + tool_input = block.input if isinstance(block.input, dict) else {} + event = push_thinking(recent, _tool_use_line(block.name, tool_input)) + if event: + events.append(event) + elif isinstance(block, TextBlock): + text = str(block.text or "") + if text.strip(): + reply_parts.append(text) + blob = "".join(reply_parts) + plain = text.replace("\n", "").strip() + if plain and len(blob) - last_compose_at >= compose_step: + tail = blob[-88:].replace("\n", " ").strip() + event = update_composing_line(recent, tail) + if event: + events.append(event) + last_compose_at = len(blob) + else: + block_type = type(block).__name__ + if block_type == "ThinkingBlock" or hasattr(block, "thinking"): + thinking = normalize_activity_line(getattr(block, "thinking", "") or "") + if thinking: + event = push_thinking(recent, f"Reasoning · {thinking}") + if event: + events.append(event) + elif block_type == "ToolUseBlock" or hasattr(block, "name"): + name = str(getattr(block, "name", "") or "tool") + tool_input = getattr(block, "input", None) or {} + if not isinstance(tool_input, dict): + tool_input = {} + event = push_thinking(recent, _tool_use_line(name, tool_input)) + if event: + events.append(event) + elif block_type == "TextBlock" or hasattr(block, "text"): + text = str(getattr(block, "text", "") or "") + if text.strip(): + reply_parts.append(text) + blob = "".join(reply_parts) + plain = text.replace("\n", "").strip() + if plain and len(blob) - last_compose_at >= compose_step: + tail = blob[-88:].replace("\n", " ").strip() + event = update_composing_line(recent, tail) + if event: + events.append(event) + last_compose_at = len(blob) + return events, last_compose_at + + +def thinking_events_from_sdk_message( + sdk_message: Any, + *, + recent: list[str], + reply_parts: list[str], + last_compose_at: int, + compose_step: int = 120, +) -> tuple[list[dict[str, Any]], int]: + """Return thinking NDJSON events and updated compose offset for one SDK message.""" + from claude_agent_sdk.types import ( + AssistantMessage, + StreamEvent, + TaskNotificationMessage, + TaskProgressMessage, + TaskStartedMessage, + ) + + events: list[dict[str, Any]] = [] + + if isinstance(sdk_message, AssistantMessage): + if isinstance(sdk_message.content, list): + return _thinking_events_from_assistant_content( + sdk_message.content, + recent=recent, + reply_parts=reply_parts, + last_compose_at=last_compose_at, + compose_step=compose_step, + ) + return events, last_compose_at + + if isinstance(sdk_message, TaskStartedMessage): + _append_task_progress_events( + events, + recent, + description=str(sdk_message.description or ""), + last_tool_name=None, + started=True, + ) + return events, last_compose_at + + if isinstance(sdk_message, TaskProgressMessage): + _append_task_progress_events( + events, + recent, + description=str(sdk_message.description or ""), + last_tool_name=sdk_message.last_tool_name, + started=False, + ) + return events, last_compose_at + + if isinstance(sdk_message, TaskNotificationMessage): + summary = str(sdk_message.summary or "").strip() + if summary: + event = push_thinking(recent, summary) + if event: + events.append(event) + return events, last_compose_at + + if isinstance(sdk_message, StreamEvent): + return _thinking_events_from_stream_event( + sdk_message.event, + recent=recent, + reply_parts=reply_parts, + last_compose_at=last_compose_at, + compose_step=compose_step, + ) + + content = getattr(sdk_message, "content", None) + if isinstance(content, list): + return _thinking_events_from_assistant_content( + content, + recent=recent, + reply_parts=reply_parts, + last_compose_at=last_compose_at, + compose_step=compose_step, + ) + + task_id = getattr(sdk_message, "task_id", None) + description = str(getattr(sdk_message, "description", "") or "").strip() + if task_id and description: + _append_task_progress_events( + events, + recent, + description=description, + last_tool_name=getattr(sdk_message, "last_tool_name", None), + started=getattr(sdk_message, "usage", None) is None + and not getattr(sdk_message, "last_tool_name", None), + ) + return events, last_compose_at + + payload = getattr(sdk_message, "event", None) + if isinstance(payload, dict): + return _thinking_events_from_stream_event( + payload, + recent=recent, + reply_parts=reply_parts, + last_compose_at=last_compose_at, + compose_step=compose_step, + ) + + return events, last_compose_at + + +def initial_sdk_thinking_lines(*, auth_mode: str, ui_mode: str) -> list[str]: + return [ + f"Claude Agent SDK query started ({auth_mode})…", + f"Mode overlay: {ui_mode}", + "Schema tools: ontology read/save, JSONL mutations, graph search", + ] diff --git a/src/agent-runtime/kartograph_agent_runtime/tools.py b/src/agent-runtime/kartograph_agent_runtime/tools.py new file mode 100644 index 000000000..7e10bd386 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/tools.py @@ -0,0 +1,206 @@ +"""Tool wiring for graph read enclave and mutation emitters.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +import httpx + +from kartograph_agent_runtime.settings import AgentRuntimeSettings + + +@dataclass(frozen=True) +class RuntimeTooling: + """HTTP-backed tools available to the Claude agent runtime.""" + + settings: AgentRuntimeSettings + + def _headers(self) -> dict[str, str]: + return {"X-Workload-Token": self.settings.workload_token} + + def _base_url(self) -> str: + return self.settings.api_base_url.rstrip("/") + + async def get_schema_authoring_guide(self) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/schema/authoring-guide" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=self._headers()) + response.raise_for_status() + return response.json() + + async def get_workspace_readiness(self) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/schema/readiness" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=self._headers()) + response.raise_for_status() + return response.json() + + async def get_schema_ontology(self) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/schema/ontology" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=self._headers()) + response.raise_for_status() + return response.json() + + async def save_schema_ontology(self, *, ontology: dict[str, Any]) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/schema/ontology" + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.put(url, headers=self._headers(), json=ontology) + response.raise_for_status() + return response.json() + + async def apply_graph_mutations(self, *, jsonl: str) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/mutations/apply" + async with httpx.AsyncClient(timeout=600.0) as client: + response = await client.post( + url, + headers=self._headers(), + json={"jsonl": jsonl}, + ) + response.raise_for_status() + return response.json() + + async def validate_graph_mutations(self, *, jsonl: str) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/mutations/validate" + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + url, + headers=self._headers(), + json={"jsonl": jsonl}, + ) + response.raise_for_status() + return response.json() + + def read_jsonl_from_workspace(self, *, relative_path: str) -> str: + from kartograph_agent_runtime.workspace_paths import read_workspace_text_file + + return read_workspace_text_file(self.settings.workspace_dir, relative_path) + + async def apply_graph_mutations_from_file(self, *, path: str) -> dict[str, Any]: + jsonl = self.read_jsonl_from_workspace(relative_path=path) + return await self.apply_graph_mutations(jsonl=jsonl) + + async def validate_graph_mutations_from_file(self, *, path: str) -> dict[str, Any]: + jsonl = self.read_jsonl_from_workspace(relative_path=path) + return await self.validate_graph_mutations(jsonl=jsonl) + + async def list_instances_by_type( + self, + *, + entity_type: str, + limit: int = 100, + offset: int = 0, + ) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/graph/instances" + params = { + "entity_type": entity_type, + "limit": str(max(1, min(limit, 500))), + "offset": str(max(0, offset)), + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=self._headers(), params=params) + response.raise_for_status() + return response.json() + + async def list_relationship_instances( + self, + *, + relationship_type: str, + source_entity_type: str | None = None, + target_entity_type: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/graph/relationships" + params: dict[str, str] = { + "relationship_type": relationship_type, + "limit": str(max(1, min(limit, 500))), + "offset": str(max(0, offset)), + } + if source_entity_type: + params["source_entity_type"] = source_entity_type + if target_entity_type: + params["target_entity_type"] = target_entity_type + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=self._headers(), params=params) + response.raise_for_status() + return response.json() + + async def check_graph_slugs( + self, + *, + entity_type: str, + slugs: list[str], + ) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/graph/check-slugs" + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + url, + headers=self._headers(), + json={"entity_type": entity_type, "slugs": slugs}, + ) + response.raise_for_status() + return response.json() + + async def search_graph_by_slug( + self, *, slug: str, entity_type: str | None = None + ) -> dict[str, Any]: + headers = self._headers() + params: dict[str, str] = {"slug": slug} + if entity_type: + params["entity_type"] = entity_type + url = f"{self._base_url()}/extraction/workloads/graph/search-by-slug" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + + async def get_extraction_jobs_config(self) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/extraction-jobs" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=self._headers()) + response.raise_for_status() + return response.json() + + async def save_extraction_jobs_config(self, *, payload: dict[str, Any]) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/extraction-jobs" + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.put(url, headers=self._headers(), json=payload) + response.raise_for_status() + return response.json() + + async def get_extraction_jobs_plan_summary(self) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/extraction-jobs/plan-summary" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=self._headers()) + response.raise_for_status() + return response.json() + + async def get_extraction_jobs_status(self) -> dict[str, Any]: + url = f"{self._base_url()}/extraction/workloads/extraction-jobs/status" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=self._headers()) + response.raise_for_status() + return response.json() + + async def propose_mutation( + self, *, operation: str, summary: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: + headers = self._headers() + url = f"{self._base_url()}/extraction/workloads/mutations/propose" + body = { + "operation": operation, + "summary": summary, + "payload": payload or {}, + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, headers=headers, json=body) + response.raise_for_status() + return response.json() + + @staticmethod + def format_tool_result(payload: dict[str, Any]) -> dict[str, Any]: + text = json.dumps(payload, indent=2) + return {"content": [{"type": "text", "text": text}]} diff --git a/src/agent-runtime/kartograph_agent_runtime/vertex.py b/src/agent-runtime/kartograph_agent_runtime/vertex.py new file mode 100644 index 000000000..0c15ce844 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/vertex.py @@ -0,0 +1,44 @@ +"""Vertex AI helpers for Claude Agent SDK in sticky session containers.""" + +from __future__ import annotations + +import os +from typing import Literal + +VertexEffortLevel = Literal["low", "medium", "high", "max"] + +# Vertex AI (direct or via OpenShell inference.local) rejects xhigh effort levels. +VERTEX_COMPATIBLE_EFFORT: VertexEffortLevel = "high" + + +def is_truthy_env(value: str | None) -> bool: + if not value: + return False + normalized = value.strip().lower() + return normalized in {"1", "true", "yes", "on"} + + +def vertex_enabled_from_env() -> bool: + return is_truthy_env(os.getenv("CLAUDE_CODE_USE_VERTEX")) + + +def build_claude_agent_env(settings) -> dict[str, str]: + """Build Claude Agent SDK env for Vertex, OpenShell inference.local, or Anthropic API.""" + env: dict[str, str] = {} + if getattr(settings, "openshell_inference_enabled", lambda: False)(): + env["ANTHROPIC_BASE_URL"] = "https://inference.local" + env["ANTHROPIC_API_KEY"] = settings.anthropic_api_key.strip() or "unused" + env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] = "1" + return env + if settings.vertex_enabled(): + env["CLAUDE_CODE_USE_VERTEX"] = "1" + if settings.vertex_project_id.strip(): + env["ANTHROPIC_VERTEX_PROJECT_ID"] = settings.vertex_project_id.strip() + region = settings.vertex_region.strip() + if region: + env["CLOUD_ML_REGION"] = region + env["VERTEXAI_LOCATION"] = region + return env + if settings.anthropic_api_key.strip(): + env["ANTHROPIC_API_KEY"] = settings.anthropic_api_key.strip() + return env diff --git a/src/agent-runtime/kartograph_agent_runtime/workspace_paths.py b/src/agent-runtime/kartograph_agent_runtime/workspace_paths.py new file mode 100644 index 000000000..0928f5e32 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/workspace_paths.py @@ -0,0 +1,21 @@ +"""Safe path resolution under the sticky session workspace mount.""" + +from __future__ import annotations + +from pathlib import Path + + +def resolve_workspace_file(workspace_dir: str, relative_path: str) -> Path: + """Resolve a user-supplied path that must stay inside the workspace root.""" + root = Path(workspace_dir).resolve() + candidate = (root / relative_path.strip()).resolve() + if root != candidate and root not in candidate.parents: + raise ValueError(f"Path must stay within workspace: {relative_path}") + if not candidate.is_file(): + raise ValueError(f"Workspace file not found: {relative_path}") + return candidate + + +def read_workspace_text_file(workspace_dir: str, relative_path: str) -> str: + """Read a text file from the workspace using a relative path.""" + return resolve_workspace_file(workspace_dir, relative_path).read_text(encoding="utf-8") diff --git a/src/agent-runtime/pyproject.toml b/src/agent-runtime/pyproject.toml new file mode 100644 index 000000000..e1de64bc5 --- /dev/null +++ b/src/agent-runtime/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "kartograph-agent-runtime" +version = "0.1.0" +description = "Sticky session Claude Agent SDK runtime for Kartograph graph management chat" +requires-python = ">=3.12" +dependencies = [ + "claude-agent-sdk>=0.2.87", + "fastapi[standard]>=0.123.9", + "httpx>=0.28.1", + "pydantic-settings>=2.12.0", +] + +[dependency-groups] +dev = [ + "pytest>=9.0.1", + "pytest-asyncio>=1.3.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "strict" +pythonpath = ["."] diff --git a/src/agent-runtime/tests/test_agent_prompt.py b/src/agent-runtime/tests/test_agent_prompt.py new file mode 100644 index 000000000..490f56e2d --- /dev/null +++ b/src/agent-runtime/tests/test_agent_prompt.py @@ -0,0 +1,159 @@ +"""Unit tests for agent system prompt assembly.""" + +from __future__ import annotations + +from kartograph_agent_runtime.agent_prompt import build_agent_system_prompt +from kartograph_agent_runtime.settings import AgentRuntimeSettings + + +def test_build_agent_system_prompt_includes_skills_tools_and_session_scope() -> None: + prompt = build_agent_system_prompt( + { + "system_prompt": "You are the Graph Management Assistant.", + "prompt_hierarchy": ["platform_security_constraints", "mode_specific_skill_pack"], + "guardrails": ["Use Kartograph schema tools only."], + "skills": { + "schema_modeling": "Read ontology before save.", + "schema_tools": "Five kartograph_* tools available.", + }, + "graph_management_ui_mode": "initial-schema-design", + }, + settings=AgentRuntimeSettings( + KARTOGRAPH_WORKLOAD_TOKEN="token", + KARTOGRAPH_KNOWLEDGE_GRAPH_ID="kg-123", + KARTOGRAPH_TENANT_ID="tenant-456", + ), + workspace_appendix="## Session workspace\nFiles here", + ) + + assert "Graph Management Assistant" in prompt + assert "Use Kartograph schema tools only." in prompt + assert "**schema_modeling**" in prompt + assert "kartograph_get_schema_ontology" in prompt + assert "Quick workflow" in prompt + assert "Failure modes" in prompt + assert "run_scanner.py" in prompt + assert "Bash" in prompt + assert "instance_generators" in prompt + assert "kg-123" in prompt + assert "tenant-456" in prompt + assert "Files here" in prompt + + +def test_build_agent_system_prompt_includes_workspace_readiness() -> None: + prompt = build_agent_system_prompt( + {"system_prompt": "Base"}, + settings=AgentRuntimeSettings( + KARTOGRAPH_WORKLOAD_TOKEN="token", + KARTOGRAPH_KNOWLEDGE_GRAPH_ID="kg-123", + ), + workspace_readiness={ + "next_action": "Run entity prepopulation for `folder`.", + "prepopulation_tasks": [ + { + "kind": "entity", + "label": "folder", + "live_instance_count": 0, + "scanner_path": "instance_generators/folder.py", + } + ], + "prepopulated_entity_types_without_instances_live": ["folder"], + "prepopulated_relationship_types_without_instances_live": [], + "prepopulated_entity_types": [ + { + "label": "folder", + "live_instance_count": 0, + "metadata_instance_count": 0, + "required_properties": ["name"], + } + ], + "blocking_reasons": ["Prepopulated entity types require instances before transition: folder"], + "transition_eligible": False, + }, + ) + + assert "Workspace readiness" in prompt + assert "Next action" in prompt + assert "instance_generators/folder.py" in prompt + assert "`folder`" in prompt + assert "kartograph_get_workspace_readiness" in prompt + assert "Read" in prompt + assert "Glob" in prompt + assert "Failure modes" in prompt + assert "dev-repair-age-graphs" in prompt + + +def test_build_agent_system_prompt_omits_tools_without_workload_token() -> None: + prompt = build_agent_system_prompt( + {"system_prompt": "Base"}, + settings=AgentRuntimeSettings(KARTOGRAPH_WORKLOAD_TOKEN=""), + ) + + assert "Quick workflow" not in prompt + assert "Base" in prompt + + +def test_build_agent_system_prompt_compact_omits_skills_and_full_tools_table() -> None: + prompt = build_agent_system_prompt( + { + "system_prompt": "You are the Graph Management Assistant.", + "skills": {"prepopulation": "Run instance_generators with Bash."}, + }, + settings=AgentRuntimeSettings( + KARTOGRAPH_WORKLOAD_TOKEN="token", + KARTOGRAPH_KNOWLEDGE_GRAPH_ID="kg-123", + ), + prompt_detail="compact", + ) + + assert "**prepopulation**" not in prompt + assert "Quick workflow" not in prompt + assert "entities_to_jsonl.py" in prompt + assert "never /tmp" in prompt.lower() or "Never /tmp" in prompt + + +def test_build_agent_system_prompt_compact_extraction_jobs_keeps_description_authoring_skill() -> None: + prompt = build_agent_system_prompt( + { + "system_prompt": "You are the Graph Management Assistant.", + "skills": { + "prepopulation": "Run instance_generators with Bash.", + "per_instance_description_authoring": "Use IGNORE lines when counterpart has more instances.", + "job_set_contract": "Save via kartograph_save_extraction_jobs_config.", + }, + "graph_management_ui_mode": "extraction-jobs", + }, + settings=AgentRuntimeSettings( + KARTOGRAPH_WORKLOAD_TOKEN="token", + KARTOGRAPH_KNOWLEDGE_GRAPH_ID="kg-123", + ), + prompt_detail="compact", + ) + + assert "**prepopulation**" not in prompt + assert "**per_instance_description_authoring**" in prompt + assert "IGNORE lines" in prompt + assert "relationship_authoring_by_entity_type" in prompt + assert "entity_type_authoring_context" in prompt + + +def test_build_agent_system_prompt_one_off_mutations_includes_tools_reference() -> None: + prompt = build_agent_system_prompt( + { + "system_prompt": "You are the Graph Management Assistant.", + "skills": { + "instance_edit_workflow": "Validate then apply.", + "schema_edit_workflow": "Read merge save ontology.", + }, + "graph_management_ui_mode": "one-off-mutations", + }, + settings=AgentRuntimeSettings( + KARTOGRAPH_WORKLOAD_TOKEN="token", + KARTOGRAPH_KNOWLEDGE_GRAPH_ID="kg-123", + ), + ) + + assert "One-off mutation tools" in prompt + assert "mutation-examples.jsonl" in prompt + assert "Bulk edits" in prompt + assert "kartograph_list_instances_by_type" in prompt diff --git a/src/agent-runtime/tests/test_executor.py b/src/agent-runtime/tests/test_executor.py new file mode 100644 index 000000000..750102e81 --- /dev/null +++ b/src/agent-runtime/tests/test_executor.py @@ -0,0 +1,253 @@ +"""Unit tests for agent runtime executor fallback mode.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +from kartograph_agent_runtime.executor import ( + _build_system_prompt, + _build_workspace_prompt_appendix, + _extract_sdk_reply, + _iter_sdk_messages_with_heartbeat, + finalize_sdk_turn_reply, + metrics_from_sdk_result, + stream_turn_events, +) +from kartograph_agent_runtime.settings import AgentRuntimeSettings + + +def test_build_workspace_prompt_appendix_prefers_sources_index(tmp_path: Path) -> None: + package_id = "pkg-1" + package_root = tmp_path / "repository-files" / "hyperfleet-api" / "pkg" / "api" + package_root.mkdir(parents=True) + (package_root / "adapter_status_types_test.go").write_text("package api\n", encoding="utf-8") + (tmp_path / "sources-index.json").write_text( + json.dumps( + { + "version": 1, + "knowledge_graph_id": "kg-1", + "sources": [ + { + "job_package_id": package_id, + "data_source_id": "ds-hyperfleet-api", + "data_source_name": "Hyperfleet API", + "repository_folder": "hyperfleet-api", + "entry_count": 142, + "repository_root": "repository-files/hyperfleet-api", + "sample_paths": ["pkg/api/adapter_status_types_test.go"], + } + ], + } + ), + encoding="utf-8", + ) + + appendix = _build_workspace_prompt_appendix( + AgentRuntimeSettings(KARTOGRAPH_WORKSPACE_DIR=str(tmp_path)) + ) + + assert "hyperfleet-api" in appendix + assert "Hyperfleet API" in appendix + assert "142 file(s)" in appendix + assert "pkg/api/adapter_status_types_test.go" in appendix + assert "Read-only" in appendix + assert "instance_generators/" in appendix + assert "entities_to_jsonl.py" in appendix + + +def test_build_workspace_prompt_appendix_includes_extension_counts(tmp_path: Path) -> None: + package_root = tmp_path / "repository-files" / "hyperfleet-api" / "pkg" / "api" + package_root.mkdir(parents=True) + (package_root / "adapter_status_types_test.go").write_text("package api\n", encoding="utf-8") + (package_root / "README.md").write_text("# docs\n", encoding="utf-8") + (tmp_path / "sources-index.json").write_text( + json.dumps( + { + "version": 1, + "knowledge_graph_id": "kg-1", + "sources": [ + { + "job_package_id": "pkg-1", + "data_source_id": "ds-hyperfleet-api", + "data_source_name": "Hyperfleet API", + "repository_folder": "hyperfleet-api", + "entry_count": 2, + "repository_root": "repository-files/hyperfleet-api", + "sample_paths": ["pkg/api/adapter_status_types_test.go"], + "file_extension_counts": {".go": 1, ".md": 1}, + } + ], + } + ), + encoding="utf-8", + ) + + appendix = _build_workspace_prompt_appendix( + AgentRuntimeSettings(KARTOGRAPH_WORKSPACE_DIR=str(tmp_path)) + ) + + assert ".go=1" in appendix + assert ".md=1" in appendix + + +def test_build_workspace_prompt_appendix_lists_materialized_repository_files( + tmp_path: Path, +) -> None: + package_root = tmp_path / "repository-files" / "hyperfleet-api" / "pkg" / "api" + package_root.mkdir(parents=True) + (package_root / "adapter_status_types_test.go").write_text("package api\n", encoding="utf-8") + + appendix = _build_workspace_prompt_appendix( + AgentRuntimeSettings(KARTOGRAPH_WORKSPACE_DIR=str(tmp_path)) + ) + + assert "repository-files//" in appendix + assert "hyperfleet-api" in appendix + assert "pkg/api/adapter_status_types_test.go" in appendix + + +def test_build_system_prompt_includes_workspace_appendix() -> None: + prompt = _build_system_prompt( + {"system_prompt": "Base prompt"}, + settings=AgentRuntimeSettings(KARTOGRAPH_WORKLOAD_TOKEN="token"), + workspace_appendix="## Session workspace\nFiles here", + ) + + assert "Base prompt" in prompt + assert "Files here" in prompt + assert "kartograph_get_schema_ontology" in prompt + + +def test_extract_sdk_reply_joins_multiple_text_blocks() -> None: + from dataclasses import dataclass + + @dataclass + class Block: + text: str + + @dataclass + class Message: + content: list + + message = Message(content=[Block(text="Part one. "), Block(text="Part two.")]) + + assert _extract_sdk_reply(message) == "Part one. Part two." + + +def test_finalize_sdk_turn_reply_prefers_streamed_text() -> None: + reply = finalize_sdk_turn_reply( + reply=None, + reply_parts=["Designed ", "entity types."], + last_result=None, + notification_summaries=[], + ) + + assert reply == "Designed entity types." + + +def test_finalize_sdk_turn_reply_uses_tool_only_completion_summary() -> None: + from dataclasses import dataclass + + @dataclass + class Result: + num_turns: int + result: str | None = None + is_error: bool = False + + reply = finalize_sdk_turn_reply( + reply=None, + reply_parts=[], + last_result=Result(num_turns=4), + notification_summaries=[], + ) + + assert "4 turn(s)" in reply + assert "without a final written reply" in reply + + +def test_finalize_sdk_turn_reply_returns_none_when_nothing_available() -> None: + reply = finalize_sdk_turn_reply( + reply=None, + reply_parts=[], + last_result=None, + notification_summaries=[], + ) + + assert reply is None + + +@pytest.mark.asyncio +async def test_sdk_message_heartbeat_does_not_cancel_pending_read() -> None: + async def delayed_messages(): + await asyncio.sleep(0.01) + yield "first" + await asyncio.sleep(0.05) + yield "second" + + collected: list[str] = [] + async for item in _iter_sdk_messages_with_heartbeat( + delayed_messages().__aiter__(), + heartbeat_seconds=0.02, + ): + if item is not None: + collected.append(str(item)) + + assert collected == ["first", "second"] + + +def test_metrics_from_sdk_result_extracts_usage_and_cost() -> None: + class _Result: + usage = { + "input_tokens": 120, + "output_tokens": 45, + "cache_read_input_tokens": 30, + "cache_creation_input_tokens": 15, + } + total_cost_usd = 0.33 + + metrics = metrics_from_sdk_result(_Result()) + + assert metrics["input_tokens"] == 120 + assert metrics["output_tokens"] == 45 + assert metrics["cache_read_tokens"] == 30 + assert metrics["cache_creation_tokens"] == 15 + assert metrics["cost_usd"] == pytest.approx(0.33) + + +def test_metrics_from_sdk_result_returns_empty_when_missing() -> None: + assert metrics_from_sdk_result(None) == {} + assert metrics_from_sdk_result(object()) == {} + + +@pytest.mark.asyncio +async def test_stream_turn_events_without_api_key_returns_done_reply( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CLAUDE_CODE_USE_VERTEX", raising=False) + monkeypatch.delenv("ANTHROPIC_VERTEX_PROJECT_ID", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + settings = AgentRuntimeSettings( + KARTOGRAPH_WORKLOAD_TOKEN="token", + KARTOGRAPH_API_BASE_URL="http://api:8000", + ANTHROPIC_API_KEY="", + ) + + events = [ + event + async for event in stream_turn_events( + settings=settings, + message="Design entity types", + ui_mode="initial-schema-design", + agent_configuration={"skills": {"schema_modeling": "guide"}}, + message_history=[], + ) + ] + + assert events[0]["type"] == "thinking" + assert events[-1]["type"] == "done" + assert events[-1]["ok"] is True + assert "Graph Management Assistant" in events[-1]["reply"] diff --git a/src/agent-runtime/tests/test_schema_tools.py b/src/agent-runtime/tests/test_schema_tools.py new file mode 100644 index 000000000..9ebc4fc93 --- /dev/null +++ b/src/agent-runtime/tests/test_schema_tools.py @@ -0,0 +1,70 @@ +"""Unit tests for Kartograph schema MCP tools.""" + +from __future__ import annotations + +from kartograph_agent_runtime.schema_tools import ( + KARTOGRAPH_SCHEMA_TOOL_NAMES, + LIST_INSTANCES_BY_TYPE_TOOL_DESCRIPTION, + build_kartograph_schema_mcp_server, +) +from kartograph_agent_runtime.settings import AgentRuntimeSettings +from kartograph_agent_runtime.tools import RuntimeTooling + + +def test_schema_tool_names_cover_authoring_surface() -> None: + assert "kartograph_get_schema_authoring_guide" in KARTOGRAPH_SCHEMA_TOOL_NAMES + assert "kartograph_get_workspace_readiness" in KARTOGRAPH_SCHEMA_TOOL_NAMES + assert "kartograph_get_schema_ontology" in KARTOGRAPH_SCHEMA_TOOL_NAMES + assert "kartograph_save_schema_ontology" in KARTOGRAPH_SCHEMA_TOOL_NAMES + assert "kartograph_validate_graph_mutations" in KARTOGRAPH_SCHEMA_TOOL_NAMES + assert "kartograph_apply_graph_mutations" in KARTOGRAPH_SCHEMA_TOOL_NAMES + assert "kartograph_apply_graph_mutations_from_file" in KARTOGRAPH_SCHEMA_TOOL_NAMES + assert "kartograph_check_graph_slugs" in KARTOGRAPH_SCHEMA_TOOL_NAMES + assert "kartograph_list_instances_by_type" in KARTOGRAPH_SCHEMA_TOOL_NAMES + assert "kartograph_list_relationship_instances" in KARTOGRAPH_SCHEMA_TOOL_NAMES + + +def test_gma_allowed_tools_include_extraction_jobs_tools() -> None: + from kartograph_agent_runtime.extraction_jobs_tools import KARTOGRAPH_EXTRACTION_JOBS_TOOL_NAMES + from kartograph_agent_runtime.schema_tools import GMA_ALLOWED_TOOL_NAMES + + for tool_name in KARTOGRAPH_EXTRACTION_JOBS_TOOL_NAMES: + assert tool_name in GMA_ALLOWED_TOOL_NAMES + + +def test_gma_allowed_tools_include_workspace_file_tools() -> None: + from kartograph_agent_runtime.schema_tools import GMA_ALLOWED_TOOL_NAMES, WORKSPACE_FILE_TOOL_NAMES + + for tool_name in WORKSPACE_FILE_TOOL_NAMES: + assert tool_name in GMA_ALLOWED_TOOL_NAMES + + +def test_gma_allowed_tools_include_write_and_edit() -> None: + from kartograph_agent_runtime.schema_tools import GMA_ALLOWED_TOOL_NAMES + + assert "Write" in GMA_ALLOWED_TOOL_NAMES + assert "Edit" in GMA_ALLOWED_TOOL_NAMES + + +def test_gma_allowed_tools_include_bash() -> None: + from kartograph_agent_runtime.schema_tools import GMA_ALLOWED_TOOL_NAMES + + assert "Bash" in GMA_ALLOWED_TOOL_NAMES + + +def test_list_instances_by_type_tool_description_mentions_mutation_ready_ids() -> None: + assert "mutation-ready" in LIST_INSTANCES_BY_TYPE_TOOL_DESCRIPTION + assert "id" in LIST_INSTANCES_BY_TYPE_TOOL_DESCRIPTION + assert "prepopulation" not in LIST_INSTANCES_BY_TYPE_TOOL_DESCRIPTION.lower() + + +def test_build_kartograph_schema_mcp_server_registers_tools() -> None: + tooling = RuntimeTooling( + settings=AgentRuntimeSettings( + KARTOGRAPH_WORKLOAD_TOKEN="token", + KARTOGRAPH_API_BASE_URL="http://api:8000", + ) + ) + server = build_kartograph_schema_mcp_server(tooling) + assert server["type"] == "sdk" + assert server["name"] == "kartograph" diff --git a/src/agent-runtime/tests/test_server.py b/src/agent-runtime/tests/test_server.py new file mode 100644 index 000000000..9b1eda4be --- /dev/null +++ b/src/agent-runtime/tests/test_server.py @@ -0,0 +1,61 @@ +"""Unit tests for agent runtime HTTP health endpoints.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from kartograph_agent_runtime import server +from kartograph_agent_runtime.runtime_auth import RUNTIME_AUTH_HEADER + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setattr(server.settings, "workspace_dir", str(tmp_path)) + monkeypatch.setattr(server.settings, "session_id", "session-test") + monkeypatch.setattr(server.settings, "runtime_auth_token", "runtime-secret") + return TestClient(server.app) + + +def test_health_returns_ok_when_workspace_marker_present( + client: TestClient, + tmp_path: Path, +) -> None: + (tmp_path / "knowledge-graph-id").write_text("kg-1", encoding="utf-8") + + response = client.get("/health") + + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_health_returns_unavailable_when_workspace_marker_missing(client: TestClient) -> None: + response = client.get("/health") + + assert response.status_code == 503 + assert response.json()["status"] == "workspace_unavailable" + + +def test_turn_requires_runtime_auth_when_token_configured( + client: TestClient, + tmp_path: Path, +) -> None: + (tmp_path / "knowledge-graph-id").write_text("kg-1", encoding="utf-8") + + unauthorized = client.post("/v1/turn", json={"message": "hello"}) + assert unauthorized.status_code == 401 + + async def fake_stream(**_kwargs): + yield {"type": "done", "ok": True} + + with patch("kartograph_agent_runtime.server.stream_turn_events", side_effect=fake_stream): + authorized = client.post( + "/v1/turn", + json={"message": "hello"}, + headers={RUNTIME_AUTH_HEADER: "runtime-secret"}, + ) + + assert authorized.status_code == 200 diff --git a/src/agent-runtime/tests/test_thinking_stream.py b/src/agent-runtime/tests/test_thinking_stream.py new file mode 100644 index 000000000..13f59dda5 --- /dev/null +++ b/src/agent-runtime/tests/test_thinking_stream.py @@ -0,0 +1,197 @@ +"""Unit tests for rolling thinking-line stream helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from kartograph_agent_runtime.thinking_stream import ( + _compose_reply_line, + initial_sdk_thinking_lines, + push_thinking, + replace_last_thinking, + thinking_events_from_sdk_message, + update_composing_line, +) + + +@dataclass +class FakeToolUseBlock: + name: str + input: dict + + +@dataclass +class FakeThinkingBlock: + thinking: str + + +@dataclass +class FakeTextBlock: + text: str + + +@dataclass +class FakeAssistantMessage: + content: list + + +@dataclass +class FakeTaskProgressMessage: + task_id: str + description: str + last_tool_name: str | None = None + usage: dict | None = None + + +@dataclass +class FakeStreamEvent: + event: dict + + +def test_initial_sdk_thinking_lines_include_connected_message() -> None: + lines = initial_sdk_thinking_lines(auth_mode="Vertex AI", ui_mode="initial-schema-design") + + assert any("Claude Agent SDK query started" in line for line in lines) + assert any("Schema tools" in line for line in lines) + + +def test_compose_reply_line_sanitizes_noisy_planning_text() -> None: + assert _compose_reply_line("need. Let me create tasks") == "Composing reply…" + assert _compose_reply_line("Short") == "Composing reply…" + assert _compose_reply_line("Summarizing ontology changes for review.") == ( + "Composing reply · Summarizing ontology changes for review." + ) + + +def test_update_composing_line_uses_sanitized_preview() -> None: + recent: list[str] = [] + event = update_composing_line(recent, "Now let me start with the ontology schema") + assert event is not None + assert recent[-1] == "Composing reply…" + + +def test_agent_runtime_settings_default_max_turns() -> None: + from kartograph_agent_runtime.settings import AgentRuntimeSettings + + settings = AgentRuntimeSettings() + + assert settings.max_turns == 500 + + +def test_agent_runtime_settings_accepts_one_hour_turn_timeout() -> None: + from kartograph_agent_runtime.settings import AgentRuntimeSettings + + settings = AgentRuntimeSettings(KARTOGRAPH_AGENT_TURN_TIMEOUT_SECONDS="3600") + + assert settings.turn_timeout_seconds == 3600.0 + + +def test_push_thinking_deduplicates_and_caps_recent_lines() -> None: + recent: list[str] = [] + for index in range(5): + push_thinking(recent, f"line-{index}") + assert recent == ["line-2", "line-3", "line-4"] + + +def test_replace_last_thinking_updates_matching_prefix_in_place() -> None: + recent = initial_sdk_thinking_lines(auth_mode="Vertex AI", ui_mode="initial-schema-design") + + first = replace_last_thinking( + recent, + "Waiting for model response… (8s)", + prefix="Waiting for model response", + ) + second = replace_last_thinking( + recent, + "Waiting for model response… (16s)", + prefix="Waiting for model response", + ) + + assert first is not None + assert second is not None + assert recent[-1] == "Waiting for model response… (16s)" + assert len(recent) == 3 + + +def test_thinking_events_from_assistant_message_tool_and_reasoning_blocks() -> None: + recent = initial_sdk_thinking_lines(auth_mode="Vertex AI", ui_mode="initial-schema-design") + message = FakeAssistantMessage( + content=[ + FakeThinkingBlock(thinking="Need to inspect entity ontology first."), + FakeToolUseBlock(name="Read", input={"file_path": "/workspace/entity_ontology.json"}), + FakeTextBlock(text="I reviewed the ontology and found three entity types."), + ], + ) + + events, _ = thinking_events_from_sdk_message( + message, + recent=recent, + reply_parts=[], + last_compose_at=0, + compose_step=10, + ) + + assert events + assert any("Reasoning" in line for line in events[-1]["recent"]) + assert any("Reading /workspace/entity_ontology.json" in line for line in events[-1]["recent"]) + + +def test_thinking_events_from_task_progress_message() -> None: + recent = initial_sdk_thinking_lines(auth_mode="Vertex AI", ui_mode="initial-schema-design") + message = FakeTaskProgressMessage( + task_id="task-1", + description="Inspecting repository files", + last_tool_name="Grep", + usage={"total_tokens": 1, "tool_uses": 1, "duration_ms": 1}, + ) + + events, _ = thinking_events_from_sdk_message( + message, + recent=recent, + reply_parts=[], + last_compose_at=0, + ) + + assert events + joined = "\n".join(events[-1]["recent"]) + assert "Inspecting repository files" in joined + assert "Running Grep" in joined + + +def test_stream_event_text_delta_accumulates_reply_parts() -> None: + recent = initial_sdk_thinking_lines(auth_mode="Vertex AI", ui_mode="initial-schema-design") + reply_parts: list[str] = [] + message = FakeStreamEvent( + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "Designed three entity types."}, + }, + ) + + thinking_events_from_sdk_message( + message, + recent=recent, + reply_parts=reply_parts, + last_compose_at=0, + compose_step=10, + ) + + assert reply_parts == ["Designed three entity types."] + + +def test_assistant_message_text_accumulates_reply_parts() -> None: + recent = initial_sdk_thinking_lines(auth_mode="Vertex AI", ui_mode="initial-schema-design") + reply_parts: list[str] = [] + message = FakeAssistantMessage( + content=[FakeTextBlock(text="Here is the proposed schema.")], + ) + + thinking_events_from_sdk_message( + message, + recent=recent, + reply_parts=reply_parts, + last_compose_at=0, + compose_step=120, + ) + + assert reply_parts == ["Here is the proposed schema."] diff --git a/src/agent-runtime/tests/test_vertex.py b/src/agent-runtime/tests/test_vertex.py new file mode 100644 index 000000000..77f525d60 --- /dev/null +++ b/src/agent-runtime/tests/test_vertex.py @@ -0,0 +1,28 @@ +"""Tests for Claude Agent SDK env construction.""" + +from __future__ import annotations + +from kartograph_agent_runtime.settings import AgentRuntimeSettings +from kartograph_agent_runtime.vertex import VERTEX_COMPATIBLE_EFFORT, build_claude_agent_env + + +def test_build_claude_agent_env_uses_openshell_inference_without_vertex_adc() -> None: + settings = AgentRuntimeSettings( + ANTHROPIC_BASE_URL="https://inference.local", + ANTHROPIC_API_KEY="unused", + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS="1", + ) + env = build_claude_agent_env(settings) + assert env["ANTHROPIC_BASE_URL"] == "https://inference.local" + assert env["ANTHROPIC_API_KEY"] == "unused" + assert "CLAUDE_CODE_USE_VERTEX" not in env + + +def test_openshell_inference_settings_count_as_model_configured() -> None: + settings = AgentRuntimeSettings(ANTHROPIC_BASE_URL="https://inference.local") + assert settings.openshell_inference_enabled() is True + assert settings.model_configured() is True + + +def test_vertex_compatible_effort_avoids_xhigh() -> None: + assert VERTEX_COMPATIBLE_EFFORT == "high" diff --git a/src/agent-runtime/tests/test_workspace_paths.py b/src/agent-runtime/tests/test_workspace_paths.py new file mode 100644 index 000000000..f92c7291d --- /dev/null +++ b/src/agent-runtime/tests/test_workspace_paths.py @@ -0,0 +1,21 @@ +"""Unit tests for workspace path resolution.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from kartograph_agent_runtime.workspace_paths import read_workspace_text_file, resolve_workspace_file + + +def test_resolve_workspace_file_rejects_path_traversal(tmp_path: Path) -> None: + (tmp_path / "safe.jsonl").write_text("{}\n", encoding="utf-8") + with pytest.raises(ValueError, match="within workspace"): + resolve_workspace_file(str(tmp_path), "../outside.jsonl") + + +def test_read_workspace_text_file_reads_relative_path(tmp_path: Path) -> None: + (tmp_path / "batch.jsonl").write_text('{"op":"CREATE"}\n', encoding="utf-8") + content = read_workspace_text_file(str(tmp_path), "batch.jsonl") + assert "CREATE" in content diff --git a/src/agent-runtime/uv.lock b/src/agent-runtime/uv.lock new file mode 100644 index 000000000..55db0662d --- /dev/null +++ b/src/agent-runtime/uv.lock @@ -0,0 +1,1446 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.2.87" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/dc/e2afd59a1dd6484b6500245fa2331a0d8c0b68e6c180bc29d8ce9540f38a/claude_agent_sdk-0.2.87.tar.gz", hash = "sha256:56f02a49a97f7be37e0cd7323494d1c09e52fb0db7ab94f53bba8a230bb4bd0e", size = 252063, upload-time = "2026-05-23T04:19:25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/4e/b83c4c6ec1e0b63e9d4d58ba9a5abfd9936c55b8ee4c06b88f5e93bdfd70/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_arm64.whl", hash = "sha256:52204a9609dec3aa96032afd48c07d72e05d13311faf614978f17b61326e6e31", size = 63037960, upload-time = "2026-05-23T04:19:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/13/d7/5fb02260c5b95c66e108c35e046d4d66011921251f7896274b6b21594f14/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1713e34e50b830ecac54386d39af14e3a2775f833f1ef715eb53566eaa1b6325", size = 65095745, upload-time = "2026-05-23T04:19:32.533Z" }, + { url = "https://files.pythonhosted.org/packages/1d/84/1061f6580bbbc78de629467abf051cdbbabe71b982297b401e3fde65c7e0/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e9e23119d2a02ad1ea1a2707214db98f5baf2c8809577186629843ddfcb8ec18", size = 72725120, upload-time = "2026-05-23T04:19:36.539Z" }, + { url = "https://files.pythonhosted.org/packages/04/50/449f5044d76d9de18cf6a9f4b1c9386a74f41b4e2da5312df245d9dd23ef/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:5ac525d9ae3481296df5639d005e12ce2b6b0427426991f35da64db30be25c6e", size = 72875504, upload-time = "2026-05-23T04:19:40.839Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/3f9d7c491d5a98138d293192b31cc9ed792d3552b3a7e276163d7fe2d43a/claude_agent_sdk-0.2.87-py3-none-win_amd64.whl", hash = "sha256:f34973669a1efaeb1543e7b22d7b22feefd8af2fae3adfd39181635077dae432", size = 73514880, upload-time = "2026-05-23T04:19:44.65Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "detect-installer" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/ce/6897d812825e9d4c53e3c7112726e800cc5231b013b2223bf64f653ff362/detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7", size = 3049, upload-time = "2026-02-23T10:40:22.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/34/8cc73273414405086c58852916e4031812a6a30fe04c057e37ad99397b7f/detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a", size = 4539, upload-time = "2026-02-23T10:40:23.807Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "detect-installer" }, + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/1d/57221a834b0f62dfa510c2b3db6e9b682cfbc280cef41919a8811ce1ff89/fastapi_cloud_cli-0.18.0.tar.gz", hash = "sha256:95f7a79200e3a90a005e068a4d8ede49d4b04accb095ccd4fd47da998fc28c74", size = 53320, upload-time = "2026-05-22T09:53:54.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/1e/1d54aabf71c003e89e73df92c3dfded311228e68db7cea5db90b3e0ef2b5/fastapi_cloud_cli-0.18.0-py3-none-any.whl", hash = "sha256:1f136fc651b0b6e2f4a9679e23c56e1c3be3405e74469c14ba6e2d5b87fdc113", size = 37087, upload-time = "2026-05-22T09:53:53.001Z" }, +] + +[[package]] +name = "fastar" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" }, + { url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" }, + { url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" }, + { url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" }, + { url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" }, + { url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" }, + { url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" }, + { url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" }, + { url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" }, + { url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kartograph-agent-runtime" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "claude-agent-sdk" }, + { name = "fastapi", extra = ["standard"] }, + { name = "httpx" }, + { name = "pydantic-settings" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "claude-agent-sdk", specifier = ">=0.2.87" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.123.9" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic-settings", specifier = ">=2.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.19.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/02/32217f3657ae91a0ea7cf1d74ade78f44352f830d00c468f753ddb3d4980/rich_toolkit-0.19.10.tar.gz", hash = "sha256:dc2e8c515ef9fbb4894e62bd41a2d2960dd7c2f505b5084894604d5ccfee3f09", size = 198167, upload-time = "2026-05-21T10:11:42.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/84/a005adcb4d1e6846ba3d62768090c3b943e3f6d8dc5c47af64f33584c4a7/rich_toolkit-0.19.10-py3-none-any.whl", hash = "sha256:93a41f67a09aefe90379f1729495c2fee9ccbcc8cfda48e2ca2ae54a995e32b1", size = 33907, upload-time = "2026-05-21T10:11:43.578Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" }, + { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.61.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/4d/3c66e6045bd2071256b6b6fdcb0cc02b86ce54b2acc2ceac79af8e0efbb5/sentry_sdk-2.61.0.tar.gz", hash = "sha256:1ca9b4bb777eb5be67004edab7eb894f21c6301f1d05ed64966719ad5d1764ce", size = 458510, upload-time = "2026-05-28T09:40:28.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/5a/9794736d5802689c1a48862e6afe6b7f3e86cc37c15d4a84bc0143877dc1/sentry_sdk-2.61.0-py3-none-any.whl", hash = "sha256:ec4d30273909cb1d198e03208b16ee70e2bc5d90a16fd9f1fb2fc6a72e1f03dc", size = 483111, upload-time = "2026-05-28T09:40:27.027Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, +] + +[[package]] +name = "typer" +version = "0.26.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/15/f5fc7be23b7196bc065b282d9589a372392fb10860c80f9c1dd7eb008662/typer-0.26.3.tar.gz", hash = "sha256:3e2b9352f535e5303ef27806dadc2c8647687bdca5c902f03fec3fb88f46a46a", size = 198326, upload-time = "2026-05-28T20:30:50.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/cc/c6c5dea061e2740355bfeef22ac6a41751bd2f3903e83921295569bdcec4/typer-0.26.3-py3-none-any.whl", hash = "sha256:e70549ec5a403ca8a0bf0802ddd9f3c6ff7a14ccbb859b01b697baa943636f33", size = 122338, upload-time = "2026-05-28T20:30:49.816Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/src/api/extraction/application/__init__.py b/src/api/extraction/application/__init__.py new file mode 100644 index 000000000..407be92a9 --- /dev/null +++ b/src/api/extraction/application/__init__.py @@ -0,0 +1,19 @@ +"""Extraction application layer. + +Application services orchestrate extraction workflows using domain logic +and port contracts. They do not directly depend on infrastructure. +""" + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.application.chat_turn_service import ExtractionChatTurnService +from extraction.application.skill_resolution_service import ( + ExtractionSkillResolutionService, + ResolvedExtractionSkillPack, +) + +__all__ = [ + "ExtractionAgentSessionService", + "ExtractionChatTurnService", + "ExtractionSkillResolutionService", + "ResolvedExtractionSkillPack", +] diff --git a/src/api/extraction/application/agent_session_service.py b/src/api/extraction/application/agent_session_service.py new file mode 100644 index 000000000..e25399f28 --- /dev/null +++ b/src/api/extraction/application/agent_session_service.py @@ -0,0 +1,353 @@ +"""Application service for extraction agent session lifecycle.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from ulid import ULID + +from extraction.application.graph_management_session_journal import ( + GraphManagementSessionJournalService, +) +from extraction.application.skill_resolution_service import ( + ExtractionSkillResolutionService, +) +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.graph_management_session_scope import resolve_backend_session_mode +from extraction.domain.value_objects import ( + BootstrapIntakePath, + ExtractionSessionMode, + ExtractionSessionRunMetric, + GraphManagementUiMode, +) +from extraction.ports.repositories import ( + IExtractionAgentSessionRepository, + IExtractionSessionRunMetricsReader, +) +from extraction.ports.runtime import IStickySessionRuntimeManager + + +@dataclass(frozen=True) +class ExtractionSessionHistoryRecord: + """Session history entry with linked run-level metrics.""" + + session: ExtractionAgentSession + run_metrics: list[ExtractionSessionRunMetric] + + +class ExtractionAgentSessionService: + """Orchestrates session create/get/list/archive behaviors by scope.""" + + def __init__( + self, + repository: IExtractionAgentSessionRepository, + skill_resolution_service: ExtractionSkillResolutionService | None = None, + run_metrics_reader: IExtractionSessionRunMetricsReader | None = None, + sticky_runtime_manager: IStickySessionRuntimeManager | None = None, + session_journal_service: GraphManagementSessionJournalService | None = None, + idle_session_ttl: timedelta = timedelta(hours=1), + ) -> None: + self._repository = repository + self._skill_resolution_service = skill_resolution_service + self._run_metrics_reader = run_metrics_reader + self._sticky_runtime_manager = sticky_runtime_manager + self._session_journal_service = session_journal_service + self._idle_session_ttl = idle_session_ttl + + @staticmethod + def _build_bootstrap_intake_prompt() -> str: + return ( + "Before we draft schema types, share your capabilities and goals for this " + "knowledge graph. Then choose one path: " + "(1) immediate first-pass schema attempt, or " + "(2) guided question-by-question co-design." + ) + + async def _expire_idle_sessions(self, user_id: str, knowledge_graph_id: str) -> None: + now = datetime.now(UTC) + if self._sticky_runtime_manager is not None: + self._sticky_runtime_manager.cleanup_expired(now=now) + + active_sessions = await self._repository.list_active_by_user_and_kg( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ) + for session in active_sessions: + if session.updated_at + self._idle_session_ttl <= now: + await self._end_session_record(session) + + async def _terminate_sticky_runtime(self, session: ExtractionAgentSession) -> None: + if self._sticky_runtime_manager is None: + return + self._sticky_runtime_manager.terminate_runtime( + session_id=session.id, + user_id=session.user_id, + knowledge_graph_id=session.knowledge_graph_id, + mode=session.mode.value, + ) + + async def _end_session_record(self, session: ExtractionAgentSession) -> None: + if not session.is_active: + return + await self._terminate_sticky_runtime(session) + if self._session_journal_service is not None: + await self._session_journal_service.archive_session_mutations(session) + session.archive() + await self._repository.save(session) + + @staticmethod + def _session_had_sticky_runtime_attempt(session: ExtractionAgentSession) -> bool: + sticky = session.runtime_context.get("sticky_runtime") + if not isinstance(sticky, dict): + return False + phase = sticky.get("phase") + return phase in {"starting", "ready", "unhealthy", "failed"} + + async def _reconcile_orphaned_sticky_session( + self, + session: ExtractionAgentSession, + ) -> ExtractionAgentSession | None: + """Archive sessions whose sticky runtime no longer exists (e.g. after sandbox delete).""" + if self._sticky_runtime_manager is None: + return session + if not self._session_had_sticky_runtime_attempt(session): + return session + + sticky = session.runtime_context.get("sticky_runtime", {}) + container_id = sticky.get("container_id") if isinstance(sticky, dict) else None + if self._sticky_runtime_manager.is_runtime_active( + session_id=session.id, + container_id=container_id if isinstance(container_id, str) else None, + user_id=session.user_id, + knowledge_graph_id=session.knowledge_graph_id, + mode=session.mode.value, + ): + return session + + await self._end_session_record(session) + return None + + async def _create_session( + self, + *, + user_id: str, + knowledge_graph_id: str, + ui_mode: GraphManagementUiMode, + ) -> ExtractionAgentSession: + mode = resolve_backend_session_mode(ui_mode) + session = ExtractionAgentSession( + id=str(ULID()), + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + graph_management_ui_mode=ui_mode, + ) + session.runtime_context["graph_management_ui_mode"] = ui_mode.value + if self._skill_resolution_service is not None: + resolved = await self._skill_resolution_service.resolve_for_graph_management_turn( + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=ui_mode, + ) + session.runtime_context["agent_configuration"] = { + "system_prompt": resolved.system_prompt, + "prompt_hierarchy": list(resolved.prompt_hierarchy), + "guardrails": list(resolved.guardrails), + "skills": dict(resolved.skills), + } + if mode == ExtractionSessionMode.SCHEMA_BOOTSTRAP: + session.message_history.append( + {"role": "assistant", "content": self._build_bootstrap_intake_prompt()} + ) + session.runtime_context["bootstrap_intake"] = { + "status": "awaiting_path_selection", + "selected_path": None, + "capabilities_goals": None, + "path_options": [ + BootstrapIntakePath.FIRST_PASS_SCHEMA_ATTEMPT.value, + BootstrapIntakePath.GUIDED_CO_DESIGN.value, + ], + } + await self._repository.save(session) + return session + + async def get_active_session( + self, + user_id: str, + knowledge_graph_id: str, + ui_mode: GraphManagementUiMode, + ) -> ExtractionAgentSession | None: + await self._expire_idle_sessions(user_id, knowledge_graph_id) + session = await self._repository.find_active_by_ui_mode( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=ui_mode, + ) + if session is None: + return None + return await self._reconcile_orphaned_sticky_session(session) + + async def start_session( + self, + user_id: str, + knowledge_graph_id: str, + ui_mode: GraphManagementUiMode, + ) -> ExtractionAgentSession: + await self._expire_idle_sessions(user_id, knowledge_graph_id) + existing = await self._repository.find_active_by_ui_mode( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=ui_mode, + ) + if existing is not None: + existing = await self._reconcile_orphaned_sticky_session(existing) + if existing is not None: + return existing + return await self._create_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=ui_mode, + ) + + async def end_session( + self, + user_id: str, + knowledge_graph_id: str, + ui_mode: GraphManagementUiMode, + ) -> ExtractionAgentSession | None: + await self._expire_idle_sessions(user_id, knowledge_graph_id) + active = await self._repository.find_active_by_ui_mode( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=ui_mode, + ) + if active is None: + return None + await self._end_session_record(active) + return active + + async def get_or_create_active_session( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode | None = None, + ) -> ExtractionAgentSession: + """Return active session for UI mode or create one (legacy chat auto-start).""" + resolved_ui_mode = ui_mode or ( + GraphManagementUiMode.INITIAL_SCHEMA_DESIGN + if mode == ExtractionSessionMode.SCHEMA_BOOTSTRAP + else GraphManagementUiMode.EXTRACTION_JOBS + ) + if resolve_backend_session_mode(resolved_ui_mode) != mode: + raise ValueError("graph_management_ui_mode does not match session mode") + existing = await self.get_active_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=resolved_ui_mode, + ) + if existing is not None: + return existing + return await self.start_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=resolved_ui_mode, + ) + + async def save_session(self, session: ExtractionAgentSession) -> ExtractionAgentSession: + """Persist session mutations after a chat turn.""" + session.updated_at = datetime.now(UTC) + await self._repository.save(session) + return session + + async def clear_chat( + self, + user_id: str, + knowledge_graph_id: str, + ui_mode: GraphManagementUiMode, + ) -> ExtractionAgentSession: + await self.end_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=ui_mode, + ) + return await self.start_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=ui_mode, + ) + + async def list_sessions( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode | None = None, + ) -> list[ExtractionAgentSession]: + return await self._repository.list_by_scope( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + + async def list_session_history( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> list[ExtractionSessionHistoryRecord]: + sessions = await self._repository.list_by_scope( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + if not sessions: + return [] + + metrics_by_session: dict[str, list[ExtractionSessionRunMetric]] = {} + if self._run_metrics_reader is not None: + metrics_by_session = await self._run_metrics_reader.find_metrics_by_session_ids( + knowledge_graph_id=knowledge_graph_id, + session_ids=[session.id for session in sessions], + ) + + return [ + ExtractionSessionHistoryRecord( + session=session, + run_metrics=metrics_by_session.get(session.id, []), + ) + for session in sessions + ] + + async def archive_session(self, session_id: str) -> ExtractionAgentSession | None: + session = await self._repository.get_by_id(session_id) + if session is None: + return None + if session.is_active: + await self._end_session_record(session) + return session + + async def set_bootstrap_intake_path_for_active_session( + self, + user_id: str, + knowledge_graph_id: str, + selected_path: BootstrapIntakePath, + capabilities_goals: str | None, + ) -> ExtractionAgentSession: + """Persist bootstrap path selection for session continuity.""" + session = await self.get_active_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + ) + if session is None: + raise ValueError("No active initial schema design session") + intake = dict(session.runtime_context.get("bootstrap_intake", {})) + intake["status"] = "path_selected" + intake["selected_path"] = selected_path.value + intake["capabilities_goals"] = capabilities_goals + intake["selected_at"] = datetime.now(UTC).isoformat() + session.runtime_context["bootstrap_intake"] = intake + session.updated_at = datetime.now(UTC) + await self._repository.save(session) + return session diff --git a/src/api/extraction/application/archive_completed_extraction_jobs.py b/src/api/extraction/application/archive_completed_extraction_jobs.py new file mode 100644 index 000000000..060a68c0b --- /dev/null +++ b/src/api/extraction/application/archive_completed_extraction_jobs.py @@ -0,0 +1,71 @@ +"""Promote completed extraction jobs into archived history with metric backfill.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from extraction.domain.extraction_job import ExtractionJobRecord, ExtractionJobStatus +from extraction.infrastructure.extraction_job_activity import job_workdir +from extraction.infrastructure.extraction_job_mutation_metrics import reconcile_mutation_metrics +from extraction.infrastructure.extraction_job_verdict import load_mutation_verdict +from extraction.infrastructure.repositories.extraction_job_repository import ExtractionJobRepository +from extraction.infrastructure.workload_runtime_settings import ExtractionWorkloadRuntimeSettings + + +def backfill_archival_metrics( + job: ExtractionJobRecord, + *, + workdir: Path, +) -> dict[str, Any]: + """Recompute graph write metrics from a persisted job workdir before archival.""" + base = { + "entities_created": job.entities_created, + "entities_modified": job.entities_modified, + "relationships_created": job.relationships_created, + "relationships_modified": job.relationships_modified, + } + verdict = load_mutation_verdict(workdir) + operations_applied = verdict.operations_applied if verdict else 0 + return reconcile_mutation_metrics( + base, + workdir=workdir, + operations_applied=operations_applied, + ) + + +async def archive_completed_extraction_jobs( + *, + repository: ExtractionJobRepository, + knowledge_graph_id: str, + settings: ExtractionWorkloadRuntimeSettings, +) -> dict[str, int]: + """Move all completed jobs to archived, backfilling metrics from workdirs when possible.""" + jobs = await repository.list_jobs_by_status( + knowledge_graph_id=knowledge_graph_id, + status=ExtractionJobStatus.COMPLETED, + ) + archived_count = 0 + metrics_backfilled_count = 0 + for job in jobs: + workdir = job_workdir( + knowledge_graph_id=job.knowledge_graph_id, + job_id=job.job_id, + settings=settings, + ) + metrics = backfill_archival_metrics(job, workdir=workdir) + prior_write_ops = job.write_ops() + new_write_ops = int(metrics.get("write_ops") or 0) + if new_write_ops > prior_write_ops: + metrics_backfilled_count += 1 + promoted = await repository.promote_completed_job_to_archived( + knowledge_graph_id=knowledge_graph_id, + job_id=job.job_id, + metrics=metrics, + ) + if promoted: + archived_count += 1 + return { + "archived_count": archived_count, + "metrics_backfilled_count": metrics_backfilled_count, + } diff --git a/src/api/extraction/application/archived_extraction_history.py b/src/api/extraction/application/archived_extraction_history.py new file mode 100644 index 000000000..49f0a3439 --- /dev/null +++ b/src/api/extraction/application/archived_extraction_history.py @@ -0,0 +1,91 @@ +"""Shape archived extraction jobs for mutation history UI.""" + +from __future__ import annotations + +from typing import Any + +from extraction.domain.extraction_job import ExtractionJobRecord + + +def archived_job_write_ops(job: ExtractionJobRecord) -> int: + """Return write op count, including DELETE lines for graph-management sessions.""" + if ( + job.strategy == "graph_management_session" + and job.applied_mutations_jsonl + ): + from extraction.domain.mutation_jsonl_metrics import metrics_from_mutation_jsonl + + return int(metrics_from_mutation_jsonl(job.applied_mutations_jsonl).get("write_ops") or 0) + return job.write_ops() + + +def serialize_archived_job(job: ExtractionJobRecord) -> dict[str, Any]: + return { + **job.to_dict(), + "jobId": job.job_id, + "jobSet": job.job_set_name, + "writeOps": archived_job_write_ops(job), + "hasMutations": bool(job.applied_mutations_jsonl), + "inputTokens": job.input_tokens, + "outputTokens": job.output_tokens, + "costUsd": job.cost_usd, + "archivedAt": job.archived_at.isoformat() if job.archived_at else None, + "strategy": job.strategy, + } + + +def group_archived_jobs_by_run_and_set( + jobs: list[ExtractionJobRecord], +) -> list[dict[str, Any]]: + """Group archived jobs by extraction run start, then job set name.""" + runs: dict[str, dict[str, Any]] = {} + for job in jobs: + run_key = job.run_started_at.isoformat() if job.run_started_at else "unknown-run" + if run_key not in runs: + runs[run_key] = { + "runStartedAt": job.run_started_at.isoformat() if job.run_started_at else None, + "jobSets": {}, + "jobCount": 0, + "writeOps": 0, + "inputTokens": 0, + "outputTokens": 0, + "costUsd": 0.0, + } + run = runs[run_key] + set_name = job.job_set_name + job_sets: dict[str, list[dict[str, Any]]] = run["jobSets"] + if set_name not in job_sets: + job_sets[set_name] = [] + job_sets[set_name].append(serialize_archived_job(job)) + run["jobCount"] += 1 + run["writeOps"] += archived_job_write_ops(job) + run["inputTokens"] += job.input_tokens + run["outputTokens"] += job.output_tokens + run["costUsd"] += job.cost_usd + + grouped: list[dict[str, Any]] = [] + for run_key in sorted(runs.keys(), reverse=True): + run = runs[run_key] + job_sets_payload = [] + for set_name in sorted(run["jobSets"].keys()): + archived_jobs = run["jobSets"][set_name] + job_sets_payload.append( + { + "jobSet": set_name, + "jobs": archived_jobs, + "jobCount": len(archived_jobs), + "writeOps": sum(int(job.get("writeOps") or 0) for job in archived_jobs), + } + ) + grouped.append( + { + "runStartedAt": run["runStartedAt"], + "jobCount": run["jobCount"], + "writeOps": run["writeOps"], + "inputTokens": run["inputTokens"], + "outputTokens": run["outputTokens"], + "costUsd": round(float(run["costUsd"]), 6), + "jobSets": job_sets_payload, + } + ) + return grouped diff --git a/src/api/extraction/application/chat_turn_service.py b/src/api/extraction/application/chat_turn_service.py new file mode 100644 index 000000000..c47ecd995 --- /dev/null +++ b/src/api/extraction/application/chat_turn_service.py @@ -0,0 +1,198 @@ +"""Orchestrates graph-management chat turns with sticky runtime and streaming events.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import Any + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.application.graph_management_session_journal import append_turn_usage_to_session +from extraction.ports.sticky_session_runtime import IStickySessionRuntimeService +from extraction.domain.value_objects import ( + ExtractionSessionMode, + GraphManagementUiMode, + SessionJobPackagePhase, +) +from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer +from extraction.ports.chat_agent import IExtractionChatAgent + + +class ExtractionChatTurnService: + """Coordinates sticky runtime, JobPackage gating, and agent execution.""" + + def __init__( + self, + *, + session_service: ExtractionAgentSessionService, + runtime_service: IStickySessionRuntimeService, + chat_agent: IExtractionChatAgent, + credential_issuer: ScopedWorkloadCredentialIssuer | None = None, + ) -> None: + self._session_service = session_service + self._runtime_service = runtime_service + self._chat_agent = chat_agent + self._credential_issuer = credential_issuer + + async def stream_runtime_warmup( + self, + *, + tenant_id: str, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + ) -> AsyncIterator[dict[str, Any]]: + async for event in self._runtime_service.stream_runtime_warmup( + tenant_id=tenant_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=ui_mode, + ): + yield event + + async def stream_chat_turn( + self, + *, + tenant_id: str, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + message: str, + ) -> AsyncIterator[dict[str, Any]]: + trimmed = message.strip() + if not trimmed: + yield { + "type": "done", + "ok": False, + "error": { + "code": "EMPTY_MESSAGE", + "message": "Message must not be empty.", + }, + } + return + + session = await self._session_service.get_active_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + ui_mode=ui_mode, + ) + if session is None: + yield { + "type": "done", + "ok": False, + "error": { + "code": "SESSION_NOT_STARTED", + "message": "Start a Graph Management Assistant session before chatting.", + }, + } + return + + async for event in self._runtime_service.ensure_runtime_for_chat( + tenant_id=tenant_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=ui_mode, + session=session, + ): + yield event + + job_package_phase = session.runtime_context.get("job_package", {}).get("phase") + if job_package_phase == SessionJobPackagePhase.AWAITING_PREPARE.value: + wait_message = ( + session.runtime_context.get("activity_lines", ["Waiting for JobPackage ingestion context."])[0] + if session.runtime_context.get("activity_lines") + else "Waiting for JobPackage ingestion context." + ) + session.message_history.append({"role": "user", "content": trimmed}) + assistant_reply = ( + f"**Waiting for ingestion context**\n\n{wait_message}\n\n" + "I'll respond with full repository-aware guidance once JobPackage " + "material is prepared for this knowledge graph." + ) + session.message_history.append({"role": "assistant", "content": assistant_reply}) + session.updated_at = datetime.now(UTC) + await self._session_service.save_session(session) + yield {"type": "done", "ok": True, "reply": assistant_reply, "wait": True} + return + + sticky = session.runtime_context.get("sticky_runtime", {}) + if sticky.get("phase") != "ready": + yield { + "type": "done", + "ok": False, + "error": { + "code": "RUNTIME_NOT_READY", + "message": "Graph Management Assistant runtime is not ready yet.", + }, + } + return + + yield { + "type": "thinking", + "recent": [ + "Contacting Graph Management Assistant…", + f"Sticky container {str(sticky.get('container_id', ''))[:8]} active", + ], + } + + workload_token: str | None = None + if self._credential_issuer is not None: + workload_token = self._credential_issuer.issue_for_sticky_session( + tenant_id=tenant_id, + knowledge_graph_id=knowledge_graph_id, + session_id=session.id, + ).token + + assistant_reply: str | None = None + stream_failed = False + async for event in self._chat_agent.stream_turn( + session=session, + user_message=trimmed, + ui_mode=ui_mode, + workload_token=workload_token, + ): + if event.get("type") == "thinking": + recent = event.get("recent") + if isinstance(recent, list): + session.runtime_context["activity_lines"] = [ + str(line) for line in recent if str(line).strip() + ] + if event.get("type") == "done": + usage = event.get("usage") + if isinstance(usage, dict) and usage: + append_turn_usage_to_session(session, usage=usage) + if event.get("ok") is True and event.get("reply"): + assistant_reply = str(event["reply"]) + elif event.get("ok") is not True: + stream_failed = True + yield event + + if assistant_reply: + session.message_history.append({"role": "user", "content": trimmed}) + session.message_history.append({"role": "assistant", "content": assistant_reply}) + session.updated_at = datetime.now(UTC) + session.runtime_context.pop("activity_lines", None) + await self._session_service.save_session(session) + elif stream_failed: + session.message_history.append({"role": "user", "content": trimmed}) + session.updated_at = datetime.now(UTC) + await self._session_service.save_session(session) + elif session.runtime_context.get("mutation_journal"): + session.updated_at = datetime.now(UTC) + await self._session_service.save_session(session) + else: + yield { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_STREAM_INCOMPLETE", + "message": ( + "Graph Management Assistant ended the turn without a final response. " + "Check sticky container logs for Vertex or SDK errors." + ), + }, + } diff --git a/src/api/extraction/application/extraction_job_target_context.py b/src/api/extraction/application/extraction_job_target_context.py new file mode 100644 index 000000000..9e81c6701 --- /dev/null +++ b/src/api/extraction/application/extraction_job_target_context.py @@ -0,0 +1,103 @@ +"""Enrich extraction job target instances with live graph context for agent workspaces.""" + +from __future__ import annotations + +from typing import Any + +from extraction.domain.extraction_job import ExtractionTargetInstance +from extraction.ports.workload_graph import WorkloadGraphNode + +_PLATFORM_MANAGED_PROPERTIES = frozenset( + { + "data_source_id", + "knowledge_graph_id", + "graph_id", + "source_path", + } +) + + +def _properties_for_entity_type( + entity_type: str, + *, + node_types: list[dict[str, Any]], +) -> tuple[str, ...]: + for node in node_types: + if str(node.get("label") or "").strip() != entity_type: + continue + required = tuple( + str(name).strip() + for name in node.get("required_properties") or () + if str(name).strip() + ) + optional = tuple( + str(name).strip() + for name in node.get("optional_properties") or () + if str(name).strip() + ) + return required + optional + return () + + +def _property_is_missing(properties: dict[str, Any], property_name: str) -> bool: + value = properties.get(property_name) + return value is None or value == "" + + +def missing_properties_for_instance( + *, + entity_type: str, + node_properties: dict[str, Any], + node_types: list[dict[str, Any]], +) -> tuple[str, ...]: + """Return ontology property names absent or empty on one live graph node.""" + missing: list[str] = [] + for property_name in _properties_for_entity_type(entity_type, node_types=node_types): + if property_name in _PLATFORM_MANAGED_PROPERTIES: + continue + if _property_is_missing(node_properties, property_name): + missing.append(property_name) + return tuple(sorted(missing)) + + +def enrich_target_instance_for_context( + instance: ExtractionTargetInstance, + *, + graph_node: WorkloadGraphNode | None, + node_types: list[dict[str, Any]], +) -> dict[str, Any]: + """Build one job-context target entry with graph id and property gaps.""" + payload = instance.to_dict() + if graph_node is None: + payload["graph_id"] = None + payload["properties_missing"] = list( + _properties_for_entity_type(instance.entity_type, node_types=node_types) + ) + return payload + + payload["graph_id"] = graph_node.id + payload["properties_missing"] = list( + missing_properties_for_instance( + entity_type=instance.entity_type, + node_properties=graph_node.properties, + node_types=node_types, + ) + ) + return payload + + +def enrich_target_instances_for_context( + instances: tuple[ExtractionTargetInstance, ...], + *, + graph_nodes_by_slug: dict[str, WorkloadGraphNode], + node_types: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Build enriched target_instances payload for job-context.json.""" + return [ + enrich_target_instance_for_context( + instance, + graph_node=graph_nodes_by_slug.get(instance.slug), + node_types=node_types, + ) + for instance in instances + ] diff --git a/src/api/extraction/application/graph_management_session_journal.py b/src/api/extraction/application/graph_management_session_journal.py new file mode 100644 index 000000000..c3c986ae0 --- /dev/null +++ b/src/api/extraction/application/graph_management_session_journal.py @@ -0,0 +1,165 @@ +"""Accumulate Graph Management Assistant mutations and archive on session end.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from ulid import ULID + +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.extraction_job import ExtractionJobRecord, ExtractionJobStatus +from extraction.domain.value_objects import ExtractionSessionMode, GraphManagementUiMode +from extraction.domain.mutation_jsonl_metrics import metrics_from_mutation_jsonl +from extraction.ports.repositories import ( + IExtractionAgentSessionRepository, + IGraphManagementSessionArchivalRepository, +) + +GRAPH_MANAGEMENT_SESSION_STRATEGY = "graph_management_session" + +_JOB_SET_BY_UI_MODE: dict[str, str] = { + GraphManagementUiMode.INITIAL_SCHEMA_DESIGN.value: ( + "Graph Management · Initial Schema Design" + ), + GraphManagementUiMode.EXTRACTION_JOBS.value: "Graph Management · Extraction Jobs", + GraphManagementUiMode.ONE_OFF_MUTATIONS.value: "Graph Management · One-off Mutations", +} + +_DEFAULT_UI_MODE_BY_SESSION_MODE: dict[ExtractionSessionMode, GraphManagementUiMode] = { + ExtractionSessionMode.SCHEMA_BOOTSTRAP: GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + ExtractionSessionMode.EXTRACTION_OPERATIONS: GraphManagementUiMode.EXTRACTION_JOBS, +} + +_USAGE_KEYS = ( + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_creation_tokens", +) + + +def _ensure_journal(session: ExtractionAgentSession) -> dict[str, object]: + journal = dict(session.runtime_context.get("mutation_journal") or {}) + if not journal.get("started_at"): + journal["started_at"] = session.created_at.isoformat() + return journal + + +def _journal_token_total(journal: dict[str, object]) -> int: + return int(journal.get("input_tokens") or 0) + int(journal.get("output_tokens") or 0) + + +def _job_set_name_for_session(session: ExtractionAgentSession) -> str: + if session.graph_management_ui_mode is not None: + return _JOB_SET_BY_UI_MODE[session.graph_management_ui_mode.value] + ui_mode = str(session.runtime_context.get("graph_management_ui_mode") or "") + if ui_mode in _JOB_SET_BY_UI_MODE: + return _JOB_SET_BY_UI_MODE[ui_mode] + default_ui_mode = _DEFAULT_UI_MODE_BY_SESSION_MODE.get(session.mode) + if default_ui_mode is not None: + return _JOB_SET_BY_UI_MODE[default_ui_mode.value] + return _JOB_SET_BY_UI_MODE[GraphManagementUiMode.INITIAL_SCHEMA_DESIGN.value] + + +def append_applied_jsonl_to_session( + session: ExtractionAgentSession, + *, + applied_jsonl: str, +) -> None: + """Append successfully applied mutation lines to the session journal.""" + chunk = applied_jsonl.strip() + if not chunk: + return + journal = _ensure_journal(session) + previous = str(journal.get("jsonl") or "").strip() + combined = "\n".join(part for part in (previous, chunk) if part) + journal["jsonl"] = combined + journal["line_count"] = sum(1 for line in combined.splitlines() if line.strip()) + session.runtime_context["mutation_journal"] = journal + + +def append_turn_usage_to_session( + session: ExtractionAgentSession, + *, + usage: dict[str, object], +) -> None: + """Accumulate token usage from one Graph Management Assistant chat turn.""" + if not usage: + return + journal = _ensure_journal(session) + for key in _USAGE_KEYS: + journal[key] = int(journal.get(key) or 0) + int(usage.get(key) or 0) + journal["cost_usd"] = float(journal.get("cost_usd") or 0.0) + float(usage.get("cost_usd") or 0.0) + session.runtime_context["mutation_journal"] = journal + + +class GraphManagementSessionJournalService: + """Persist per-session mutation JSONL and archive as one extraction job.""" + + def __init__( + self, + *, + session_repository: IExtractionAgentSessionRepository, + extraction_job_repository: IGraphManagementSessionArchivalRepository, + ) -> None: + self._session_repository = session_repository + self._extraction_job_repository = extraction_job_repository + + async def append_applied_jsonl( + self, + *, + session_id: str, + applied_jsonl: str, + ) -> None: + session = await self._session_repository.get_by_id(session_id) + if session is None or not session.is_active: + return + append_applied_jsonl_to_session(session, applied_jsonl=applied_jsonl) + await self._session_repository.save(session) + + async def archive_session_mutations(self, session: ExtractionAgentSession) -> None: + """Write one ARCHIVED extraction job row for the full GMA session.""" + journal = session.runtime_context.get("mutation_journal") or {} + jsonl = str(journal.get("jsonl") or "").strip() + metrics = metrics_from_mutation_jsonl(jsonl) if jsonl else {} + write_ops = int(metrics.get("write_ops") or 0) + if write_ops <= 0: + return + + now = datetime.now(UTC) + started_at = session.created_at + started_raw = journal.get("started_at") + if isinstance(started_raw, str): + try: + started_at = datetime.fromisoformat(started_raw) + except ValueError: + started_at = session.created_at + + record = ExtractionJobRecord( + id=str(ULID()), + knowledge_graph_id=session.knowledge_graph_id, + job_id=f"gma-{session.id}", + job_set_name=_job_set_name_for_session(session), + strategy=GRAPH_MANAGEMENT_SESSION_STRATEGY, + status=ExtractionJobStatus.ARCHIVED, + order_index=0, + description=( + f"Graph Management Assistant session {session.id} " + f"({session.mode.value.replace('_', ' ')})" + ), + run_started_at=started_at, + started_at=started_at, + completed_at=now, + archived_at=now, + applied_mutations_jsonl=jsonl or None, + input_tokens=int(journal.get("input_tokens") or 0), + output_tokens=int(journal.get("output_tokens") or 0), + cache_read_tokens=int(journal.get("cache_read_tokens") or 0), + cache_creation_tokens=int(journal.get("cache_creation_tokens") or 0), + cost_usd=float(journal.get("cost_usd") or 0.0), + entities_created=int(metrics.get("entities_created") or 0), + entities_modified=int(metrics.get("entities_modified") or 0), + relationships_created=int(metrics.get("relationships_created") or 0), + relationships_modified=int(metrics.get("relationships_modified") or 0), + ) + await self._extraction_job_repository.insert_archived_session_job(record) diff --git a/src/api/extraction/application/job_package_gate.py b/src/api/extraction/application/job_package_gate.py new file mode 100644 index 000000000..4f88b33ff --- /dev/null +++ b/src/api/extraction/application/job_package_gate.py @@ -0,0 +1,53 @@ +"""Pure helpers for JobPackage readiness gating in chat turns.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from extraction.domain.value_objects import ( + GraphManagementUiMode, + IngestionReadinessSnapshot, + SessionJobPackagePhase, +) + + +@dataclass(frozen=True) +class JobPackageGateDecision: + """Resolved JobPackage gate for one chat turn.""" + + phase: SessionJobPackagePhase + wait_message: str | None = None + + +def resolve_job_package_gate( + *, + ui_mode: GraphManagementUiMode, + readiness: IngestionReadinessSnapshot, +) -> JobPackageGateDecision: + """Return whether a chat turn must wait for JobPackage context.""" + if ui_mode in { + GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + GraphManagementUiMode.ONE_OFF_MUTATIONS, + }: + return JobPackageGateDecision(phase=SessionJobPackagePhase.NOT_REQUIRED) + + if readiness.data_source_count == 0: + return JobPackageGateDecision( + phase=SessionJobPackagePhase.AWAITING_PREPARE, + wait_message=( + "Waiting for a connected data source. Add and prepare data sources " + "under Data sources before extraction job chat can run." + ), + ) + + if readiness.prepared_source_count < readiness.data_source_count: + return JobPackageGateDecision( + phase=SessionJobPackagePhase.AWAITING_PREPARE, + wait_message=( + "Waiting for JobPackage ingestion context. Prepare all data sources " + f"({readiness.prepared_source_count}/{readiness.data_source_count} ready) " + "so the sticky session container can load repository files." + ), + ) + + return JobPackageGateDecision(phase=SessionJobPackagePhase.READY) diff --git a/src/api/extraction/application/repository_workspace_paths.py b/src/api/extraction/application/repository_workspace_paths.py new file mode 100644 index 000000000..e049386c5 --- /dev/null +++ b/src/api/extraction/application/repository_workspace_paths.py @@ -0,0 +1,18 @@ +"""Filesystem-safe folder names for sticky session repository materialization.""" + +from __future__ import annotations + +import re + +_UNSAFE_CHARS = re.compile(r"[^a-z0-9]+") +_MULTI_DASH = re.compile(r"-{2,}") + + +def repository_folder_for_data_source(*, name: str, data_source_id: str) -> str: + """Derive a stable, human-readable directory name for one data source.""" + slug = _UNSAFE_CHARS.sub("-", name.strip().lower()).strip("-") + slug = _MULTI_DASH.sub("-", slug) + if slug: + return slug + fallback = _UNSAFE_CHARS.sub("-", data_source_id.strip().lower()).strip("-") + return fallback or "data-source" diff --git a/src/api/extraction/application/schema_authoring_guide.py b/src/api/extraction/application/schema_authoring_guide.py new file mode 100644 index 000000000..dce20cbca --- /dev/null +++ b/src/api/extraction/application/schema_authoring_guide.py @@ -0,0 +1,328 @@ +"""Schema authoring guide shared by API workload tools and agent runtime skills.""" + +SCHEMA_AUTHORING_GUIDE = """ +# Kartograph schema authoring (Graph Management Assistant) + +Use the Kartograph schema tools — never probe undocumented HTTP routes. + +## Workspace layout + +| Path | Access | Purpose | +|------|--------|---------| +| `repository-files//` | read-only | Source repos for Glob/Grep/Read | +| `instance_generators/` | **writable** | `{label}.py` scanners + `out/*_instances.json(l)` | +| rest of workspace | **writable** | Session metadata, agent-authored files | + +Never write to `/tmp` — files there are outside the sticky workspace and cannot be used with +apply-from-file. If `instance_generators/` is not writable, report the error; do not work around it. + +Read `instance_generators/PREPOPULATION_WORKFLOW.md` for the numbered six-step entity pipeline, +relationship workflow, slug rules, batch sizes, and verification checklist. + +Bundled platform scripts (do not edit): `entities_to_jsonl.py`, `relationships_to_jsonl.py`, +`preview_instances.py`, `run_scanner.py`, `scanner_common.py`. +Copy `_entity_scanner.example.py` to `{Label}.py` — **filename must match ontology label exactly** +(case-sensitive: `E2ETest.py`, not `e2etest.py`). Domain references: `instance_generators/examples/`. + +## Bootstrap workflow (6 phases) + +1. **Understand goals** — 3–5 questions the graph must answer. +2. **Workspace discovery** — Glob/Grep under `repository-files/`. +3. **Draft schema + Q&A** — types, properties, relationships; mark `prepopulated: true` where needed. +4. **Prepopulation planning** — which types get scanners (during design only). +5. **Save ontology** — after user confirms the full schema. +6. **Implement prepopulation** — one prepopulated label per turn (below). + +## Prepopulation execution + +Start prepopulation immediately **only when all are true**: + +1. `kartograph_save_schema_ontology` succeeded. +2. `kartograph_get_workspace_readiness` returns **200** (not 500/503). +3. Readiness shows prepopulated gaps (`next_action` / `prepopulation_tasks` name a label). +4. No systemic server errors on schema tools in this session. + +If readiness is unavailable after a successful schema save, **stop and report** — do not advance to +the next prepopulated label. + +When readiness shows gaps and the checks above pass, **execute immediately** — do not ask permission. + +**First prepopulated entity type only:** smoke-test the pipeline with 1–2 instances before the full +batch (`preview_instances.py --limit 2`, validate, apply, verify with +`kartograph_list_instances_by_type`). Then run the full scanner output. + +**Entities** (all entity gaps before any relationship gap). Prefer `run_scanner.py`: + +```bash +python3 instance_generators/run_scanner.py E2ETest --entity +# kartograph_apply_graph_mutations_from_file path= +``` + +Manual pipeline: + +```bash +python3 instance_generators/E2ETest.py repository-files > instance_generators/out/E2ETest_instances.json +python3 instance_generators/preview_instances.py E2ETest --limit 5 +python3 instance_generators/entities_to_jsonl.py E2ETest \\ + --data-source-id schema-bootstrap \\ + instance_generators/out/E2ETest_instances.json > instance_generators/out/E2ETest_instances.jsonl +# apply-from-file path=instance_generators/out/E2ETest_instances.jsonl +``` + +Apply pre-validates internally; validate-from-file is an optional dry run. Apply responses include +`next_action` and remaining prepopulation gaps — use those instead of polling readiness after +every batch when chaining scanners. + +**Relationships** (after entity slugs exist; files use `{source}_{rel}_{target}_instances.*`): + +```bash +python3 instance_generators/run_scanner.py \\ + --relationship --source ComponentTest --rel tests --target APIEndpoint +``` + +Scanner stdout contract: +- Entities: `[{"slug": "...", "properties": {...}}]` +- Relationships: `[{"source_slug": "...", "target_slug": "...", "properties": {}}]` + +## Schema modeling rules + +- **Property vs entity:** categorize → property; track instances/relationships → entity + edges. +- Set `bidirectional: false` only for asymmetric edges (`depends_on`, `created_by`). + +## Relationship types (authoring vs UI) + +### Unique edge labels (required) + +Every `edge_types[].label` must be **unique** within the ontology. The platform stores edge types by +label; duplicate labels are rejected or silently collapse to one definition — **never** author six +entries all named `tests` or two named `covered_by`. + +**When the operator wants N rows in the Relationship ontology UI** (one row per source → target +pair), create **N primary `edge_types` entries with N distinct labels** — one concrete +`source_labels` + `target_labels` pair each (single element in each array). Assign a unique label +per row (e.g. `tests_ct_api`, `tests_e2e_adapter`, `covered_by_us_ct`). Set a distinct +`inverse_label` per entry when bidirectional (e.g. `appears_in_ct_api`, `covers_us_ct`). + +Example — eight UI rows for eight endpoint pairs (labels illustrative; adjust naming to taste): + +| UI row | `label` | `source_labels` | `target_labels` | `inverse_label` | +|--------|---------|-----------------|-----------------|-----------------| +| 1 | `tests_ct_api` | `["ComponentTest"]` | `["APIEndpoint"]` | `appears_in_ct_api` | +| 2 | `tests_ct_adapter` | `["ComponentTest"]` | `["Adapter"]` | `appears_in_ct_adapter` | +| … | … | … | … | … | +| 8 | `covered_by_us_e2e` | `["UserStory"]` | `["E2ETest"]` | `covers_us_e2e` | + +After save, call `kartograph_get_schema_ontology` and confirm **eight primary** edge types exist +(`auto_generated` / `inverse_of` entries are inverses — the UI hides them). **Never** claim “8 types +saved” until read-back shows eight distinct primary labels. + +**Relationship scanners:** `--rel` must match the saved `label` for that row (e.g. +`--rel tests_ct_api`, not `--rel tests` when the ontology label is `tests_ct_api`). + +### How the UI counts rows + +Design artifacts show **one row per primary relationship label**. Inverse types are **not** +separate rows; each row shows `primary / inverse` badges (e.g. `tests_ct_api / appears_in_ct_api`). + +**Bidirectional (default):** author **one primary direction only** per label. Do **not** add the +inverse as its own authored `edge_types` entry — the platform auto-generates it on save. + +### Semantic grouping vs UI rows + +**Count relationship types by stored label**, not by endpoint pair alone. Two patterns: + +1. **Few semantic types, few UI rows:** one label (e.g. `tests`) with one representative pair; other + endpoint combinations get relationship **instances** via extraction jobs later. +2. **Many UI rows:** many labels (unique per pair) as in the table above — report the count from + read-back primary entries, not “8 combinations” while only two labels exist. + +**Multi-label arrays (advanced):** one entry may list multiple entity types in `source_labels` / +`target_labels`, but the UI shows **one row** using `source_labels[0]` → `target_labels[0]` only. +Do not promise N×M separate UI rows without N×M distinct primary labels. + +**After every schema save or relationship edit:** call `kartograph_get_schema_ontology` and report +what is stored — for each **primary** edge type: `label`, `source_labels`, `target_labels`, +`inverse_label`, `bidirectional`, `prepopulated`. Do not claim combinations or counts not in that +payload. + +**User-facing summaries must match stored ontology:** + +- ✅ “8 relationship types in the UI: `tests_ct_api`, `tests_ct_adapter`, … (each bidirectional).” +- ✅ “2 relationship types: `tests`, `covered_by` — one representative pair each; other pairs via extraction jobs.” +- ❌ “Saved 8 types: ComponentTest|tests|APIEndpoint, …” when read-back shows only two labels. +- ❌ Listing auto-generated inverses as types you authored. + +## Workspace discovery patterns + +| Target | Glob / Grep hints | +|--------|-------------------| +| Tests | `**/*_test.go`, `**/test_*.go`, `**/*_test.py` | +| API endpoints | route registrations, `@app.`, `HandleFunc`, OpenAPI paths | +| Source files | `Glob **/*.{go,py,ts,java,yaml,md}` per data source | + +## Tool workflow + +1. `kartograph_get_schema_authoring_guide` · `kartograph_get_workspace_readiness` · `kartograph_get_schema_ontology` +2. `kartograph_save_schema_ontology` when schema is confirmed +3. Prepopulation pipeline above per gap +4. `kartograph_apply_graph_mutations_from_file` (apply pre-validates; validate is optional dry run) +5. Verify with `kartograph_list_instances_by_type` and readiness when apply does not return next_action + +## Entity type shape + +```json +{ + "label": "test", + "description": "Automated test file", + "required_properties": ["name"], + "optional_properties": ["file_path"], + "prepopulated": true, + "prepopulated_instance_count": 0 +} +``` + +Scanner script convention: `instance_generators/{Label}.py` → `out/{Label}_instances.json` +(case-sensitive `{Label}` matching ontology). + +## Slug and property rules + +- Slugs: lowercase snake_case via `scanner_common.generate_slug()`; dedupe with `dedupe_instances()`. +- Required properties: see `required_properties` on each type in ontology/readiness — include in every instance. +- Optional properties: omit or use empty defaults when source data is incomplete. +- Single deliverable (one entity type): run the full pipeline without stopping. +- Multiple deliverables: one label per turn, then report and continue. + +## Relationship type shape + +One primary entry per UI row (single source/target pair; **unique `label`**): + +```json +{ + "label": "tests_ct_api", + "description": "ComponentTest validates APIEndpoint behavior", + "source_labels": ["ComponentTest"], + "target_labels": ["APIEndpoint"], + "prepopulated": false, + "bidirectional": true, + "inverse_label": "appears_in_ct_api" +} +``` + +Do **not** also add `appears_in_ct_api` as its own `edge_types` entry — that inverse is auto-generated on save. + +**Prepopulation / scanners:** one concrete triple per run; `--rel` equals the saved `label`: +`run_scanner.py --relationship --source ComponentTest --rel tests_ct_api --target APIEndpoint`. +Output: `out/{source}_{label}_{target}_instances.json` (primary direction only; platform adds twin inverse edges on apply). + +## Instance mutations (JSONL) + +- Supported ops: **CREATE**, **UPDATE**, and **DELETE** for nodes and edges. +- CREATE requires `data_source_id` and `slug` on nodes. Put `source_path` in scanner `properties` when needed. +- CREATE is strict — duplicate ids/slugs fail validation; use UPDATE or DELETE for existing instances. +- DELETE removes a node or edge by `id` (edges before nodes when batching deletes manually). +- Never hand-author bulk CREATE lines in chat; use `entities_to_jsonl.py` / `relationships_to_jsonl.py`. +- Create all entity nodes before relationship edges unless you are correcting data with UPDATE/DELETE. + +## One-off mutations (Graph Management Assistant) + +Use this workflow when the UI mode is **one-off-mutations** — the operator asks for specific schema or instance edits and you apply them directly. + +### Decision tree + +| Request | Tool path | +|---------|-----------| +| Add/change entity or relationship **types** | Read ontology → propose delta → `kartograph_save_schema_ontology` | +| Create/update/delete **instances** | Search/list targets → JSONL → validate → apply | +| Mixed | Schema save first, then instance JSONL | + +### JSONL examples + +Bundled at `helpers/mutation-examples.jsonl` in the workspace. Canonical shapes: + +```json +{"op":"UPDATE","type":"node","id":"adapter:abc123def4567890","set_properties":{"transport":"maestro"}} +{"op":"CREATE","type":"edge","id":"edge:...","label":"tests_ct_api","start_id":"...","end_id":"...","set_properties":{"data_source_id":"manual-edit"}} +{"op":"DELETE","type":"node","id":"adapter:deadbeefdeadbeef"} +``` + +Rules: both `op` and `type` on every line; `set_properties` not `properties`; UPDATE/DELETE need top-level `id`. + +### Workflow (small edits, ≤5 lines) + +1. `kartograph_get_schema_ontology` — always before edits +2. Resolve targets: one `kartograph_list_instances_by_type` or `kartograph_search_graph_by_slug` +3. `kartograph_validate_graph_mutations` → `kartograph_apply_graph_mutations` +4. Verify with list/search; report write op counts + +### Bulk instance operations (5+ deletes/creates/updates) + +Use when the operator asks to replace, prune, reconcile, or keep-only a set of instances. + +**Mental model:** classify delete vs create → query once per type → generate JSONL in batch → validate once → apply once → done. + +1. **List, don't loop search** — `kartograph_list_instances_by_type` returns `id`, `slug`, and `properties` (mutation-ready). Paginate with `offset` until you cover `total`. Filter by `data_source_id`, slug, or path in Bash/python. Do **not** call `search_by_slug` per instance. +2. **Generate JSONL programmatically** — save list output to `helpers/current_