From c5f950c2c08ca953cc579bb34fc1a43cbeca6035 Mon Sep 17 00:00:00 2001 From: moze Date: Wed, 6 May 2026 00:49:16 +0200 Subject: [PATCH] Initial commit: trueref v0.1.0-SNAPSHOT Java 21 / Spring Boot 3.5.3 multi-module Maven project. Hybrid BM25+HNSW search with RRF, cross-encoder reranker, ONNX Runtime 1.22.0 (CPU + CUDA 12 GPU variants). --- .gitea/workflows/docker.yml | 85 + .gitignore | 30 + ARCHITECTURE.md | 428 ++++ CODE_STYLE.md | 91 + Dockerfile | 51 + Dockerfile.gpu | 69 + FINDINGS.md | 207 ++ README.md | 21 + pom.xml | 198 ++ tests/quality/phaser_rag_eval.py | 611 +++++ trueref | 115 + trueref-adapters/pom.xml | 106 + .../com/trueref/adapter/in/mcp/McpConfig.java | 24 + .../trueref/adapter/in/mcp/McpProperties.java | 22 + .../adapter/in/mcp/TrueRefMcpTools.java | 297 +++ .../trueref/adapter/in/mcp/package-info.java | 16 + .../adapter/in/rest/ErrorResponse.java | 16 + .../in/rest/GlobalExceptionHandler.java | 120 + .../adapter/in/rest/JobController.java | 145 ++ .../in/rest/ObservabilityController.java | 200 ++ .../adapter/in/rest/OpenApiConfig.java | 25 + .../adapter/in/rest/RepositoryController.java | 156 ++ .../adapter/in/rest/ResolveController.java | 41 + .../adapter/in/rest/SearchController.java | 47 + .../trueref/adapter/in/rest/WebConfig.java | 54 + .../trueref/adapter/in/rest/dto/JobDto.java | 33 + .../adapter/in/rest/dto/JobLogEventDto.java | 16 + .../adapter/in/rest/dto/JobStageDto.java | 32 + .../rest/dto/RegisterRepositoryRequest.java | 45 + .../adapter/in/rest/dto/RepositoryDto.java | 39 + .../adapter/in/rest/dto/ResolveMatchDto.java | 28 + .../adapter/in/rest/dto/ResolveRequest.java | 17 + .../adapter/in/rest/dto/ResolveResponse.java | 7 + .../in/rest/dto/ResolveVersionRefDto.java | 13 + .../adapter/in/rest/dto/SearchHitDto.java | 37 + .../adapter/in/rest/dto/SearchRequest.java | 41 + .../adapter/in/rest/dto/SearchResponse.java | 8 + .../adapter/in/rest/dto/TagPatternDto.java | 41 + .../adapter/in/rest/dto/VersionDto.java | 31 + .../adapter/in/rest/dto/package-info.java | 3 + .../trueref/adapter/in/rest/package-info.java | 6 + .../db/migration/V1__init_schema.sql | 67 + trueref-application/pom.xml | 28 + .../application/catalog/CatalogService.java | 89 + .../application/ingest/DiscoveryService.java | 87 + .../ingest/IngestionOrchestrator.java | 604 +++++ .../observability/InMemoryJobEventBus.java | 71 + .../observability/JobObservationService.java | 47 + .../observability/package-info.java | 3 + .../com/trueref/application/package-info.java | 3 + .../application/resolve/LibraryResolver.java | 161 ++ .../search/HybridSearchService.java | 238 ++ trueref-bootstrap/pom.xml | 68 + .../trueref/bootstrap/ApplicationBeans.java | 89 + .../trueref/bootstrap/ScheduledPoller.java | 57 + .../bootstrap/StaleJobCleanupStartup.java | 59 + .../trueref/bootstrap/TrueRefApplication.java | 17 + .../src/main/resources/application.yml | 114 + trueref-bootstrap/src/main/scripts/trueref | 62 + trueref-domain/pom.xml | 21 + .../trueref/domain/error/IngestionFailed.java | 9 + .../domain/error/InvalidSearchRequest.java | 7 + .../error/RepositoryAlreadyRegistered.java | 7 + .../domain/error/RepositoryNotFound.java | 7 + .../com/trueref/domain/error/TagNotFound.java | 7 + .../domain/error/TrueRefException.java | 25 + .../trueref/domain/error/VersionNotFound.java | 7 + .../domain/error/VersionNotIndexed.java | 8 + .../trueref/domain/error/package-info.java | 5 + .../java/com/trueref/domain/model/Chunk.java | 18 + .../com/trueref/domain/model/ChunkId.java | 16 + .../trueref/domain/model/ChunkVersion.java | 8 + .../com/trueref/domain/model/Embedding.java | 19 + .../trueref/domain/model/IngestionJob.java | 25 + .../java/com/trueref/domain/model/JobId.java | 16 + .../com/trueref/domain/model/JobLogEvent.java | 20 + .../com/trueref/domain/model/JobStage.java | 37 + .../com/trueref/domain/model/JobStatus.java | 9 + .../com/trueref/domain/model/JobType.java | 8 + .../com/trueref/domain/model/Repository.java | 37 + .../trueref/domain/model/RepositoryId.java | 17 + .../com/trueref/domain/model/SearchHit.java | 18 + .../com/trueref/domain/model/SearchScope.java | 16 + .../com/trueref/domain/model/TagPattern.java | 24 + .../com/trueref/domain/model/Version.java | 15 + .../com/trueref/domain/model/VersionId.java | 16 + .../trueref/domain/model/VersionStatus.java | 14 + .../trueref/domain/model/package-info.java | 7 + .../domain/port/in/DiscoverVersions.java | 12 + .../trueref/domain/port/in/IndexVersion.java | 12 + .../trueref/domain/port/in/ObserveJobs.java | 27 + .../trueref/domain/port/in/QueryCatalog.java | 17 + .../domain/port/in/RegisterRepository.java | 32 + .../domain/port/in/ResolveLibraryId.java | 40 + .../domain/port/in/SearchLibraryDocs.java | 30 + .../trueref/domain/port/in/package-info.java | 6 + trueref-frontend/pom.xml | 63 + trueref-frontend/web/.gitignore | 9 + trueref-frontend/web/.npmrc | 1 + trueref-frontend/web/.prettierrc | 9 + trueref-frontend/web/package-lock.json | 2202 +++++++++++++++++ trueref-frontend/web/package.json | 22 + trueref-frontend/web/src/app.css | 190 ++ trueref-frontend/web/src/app.d.ts | 12 + trueref-frontend/web/src/app.html | 13 + trueref-frontend/web/src/lib/api.ts | 119 + .../web/src/lib/components/BarChart.svelte | 73 + .../web/src/lib/components/CodeBlock.svelte | 108 + .../web/src/lib/components/JobRow.svelte | 69 + .../web/src/lib/components/LogTail.svelte | 169 ++ .../web/src/lib/components/RepoCard.svelte | 85 + .../web/src/lib/components/Sparkline.svelte | 54 + .../src/lib/components/StageProgress.svelte | 71 + .../src/lib/components/ToastContainer.svelte | 67 + .../src/lib/components/VersionBadge.svelte | 50 + trueref-frontend/web/src/lib/format.ts | 45 + trueref-frontend/web/src/lib/sse.ts | 186 ++ trueref-frontend/web/src/lib/toast.ts | 27 + trueref-frontend/web/src/lib/types.ts | 173 ++ .../web/src/routes/+layout.svelte | 187 ++ trueref-frontend/web/src/routes/+layout.ts | 4 + trueref-frontend/web/src/routes/+page.svelte | 218 ++ .../web/src/routes/jobs/+page.svelte | 128 + .../web/src/routes/jobs/[id]/+page.svelte | 91 + .../web/src/routes/repositories/+page.svelte | 160 ++ .../src/routes/repositories/[id]/+page.svelte | 232 ++ .../web/src/routes/resources/+page.svelte | 103 + .../web/src/routes/search/+page.svelte | 246 ++ trueref-frontend/web/static/favicon.svg | 1 + trueref-frontend/web/svelte.config.js | 18 + trueref-frontend/web/tsconfig.json | 14 + trueref-frontend/web/vite.config.ts | 13 + 132 files changed, 11287 insertions(+) create mode 100644 .gitea/workflows/docker.yml create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CODE_STYLE.md create mode 100644 Dockerfile create mode 100644 Dockerfile.gpu create mode 100644 FINDINGS.md create mode 100644 README.md create mode 100644 pom.xml create mode 100644 tests/quality/phaser_rag_eval.py create mode 100755 trueref create mode 100644 trueref-adapters/pom.xml create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpConfig.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpProperties.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/TrueRefMcpTools.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/package-info.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ErrorResponse.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/GlobalExceptionHandler.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/JobController.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ObservabilityController.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/OpenApiConfig.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/RepositoryController.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ResolveController.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/SearchController.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/WebConfig.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobDto.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobLogEventDto.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobStageDto.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/RegisterRepositoryRequest.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/RepositoryDto.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveMatchDto.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveRequest.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveResponse.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveVersionRefDto.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchHitDto.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchRequest.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchResponse.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/TagPatternDto.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/VersionDto.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/package-info.java create mode 100644 trueref-adapters/src/main/java/com/trueref/adapter/in/rest/package-info.java create mode 100644 trueref-adapters/src/main/resources/db/migration/V1__init_schema.sql create mode 100644 trueref-application/pom.xml create mode 100644 trueref-application/src/main/java/com/trueref/application/catalog/CatalogService.java create mode 100644 trueref-application/src/main/java/com/trueref/application/ingest/DiscoveryService.java create mode 100644 trueref-application/src/main/java/com/trueref/application/ingest/IngestionOrchestrator.java create mode 100644 trueref-application/src/main/java/com/trueref/application/observability/InMemoryJobEventBus.java create mode 100644 trueref-application/src/main/java/com/trueref/application/observability/JobObservationService.java create mode 100644 trueref-application/src/main/java/com/trueref/application/observability/package-info.java create mode 100644 trueref-application/src/main/java/com/trueref/application/package-info.java create mode 100644 trueref-application/src/main/java/com/trueref/application/resolve/LibraryResolver.java create mode 100644 trueref-application/src/main/java/com/trueref/application/search/HybridSearchService.java create mode 100644 trueref-bootstrap/pom.xml create mode 100644 trueref-bootstrap/src/main/java/com/trueref/bootstrap/ApplicationBeans.java create mode 100644 trueref-bootstrap/src/main/java/com/trueref/bootstrap/ScheduledPoller.java create mode 100644 trueref-bootstrap/src/main/java/com/trueref/bootstrap/StaleJobCleanupStartup.java create mode 100644 trueref-bootstrap/src/main/java/com/trueref/bootstrap/TrueRefApplication.java create mode 100644 trueref-bootstrap/src/main/resources/application.yml create mode 100755 trueref-bootstrap/src/main/scripts/trueref create mode 100644 trueref-domain/pom.xml create mode 100644 trueref-domain/src/main/java/com/trueref/domain/error/IngestionFailed.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/error/InvalidSearchRequest.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/error/RepositoryAlreadyRegistered.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/error/RepositoryNotFound.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/error/TagNotFound.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/error/TrueRefException.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/error/VersionNotFound.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/error/VersionNotIndexed.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/error/package-info.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/Chunk.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/ChunkId.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/ChunkVersion.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/Embedding.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/IngestionJob.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/JobId.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/JobLogEvent.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/JobStage.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/JobStatus.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/JobType.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/Repository.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/RepositoryId.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/SearchHit.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/SearchScope.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/TagPattern.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/Version.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/VersionId.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/VersionStatus.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/model/package-info.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/port/in/DiscoverVersions.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/port/in/IndexVersion.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/port/in/ObserveJobs.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/port/in/QueryCatalog.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/port/in/RegisterRepository.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/port/in/ResolveLibraryId.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/port/in/SearchLibraryDocs.java create mode 100644 trueref-domain/src/main/java/com/trueref/domain/port/in/package-info.java create mode 100644 trueref-frontend/pom.xml create mode 100644 trueref-frontend/web/.gitignore create mode 100644 trueref-frontend/web/.npmrc create mode 100644 trueref-frontend/web/.prettierrc create mode 100644 trueref-frontend/web/package-lock.json create mode 100644 trueref-frontend/web/package.json create mode 100644 trueref-frontend/web/src/app.css create mode 100644 trueref-frontend/web/src/app.d.ts create mode 100644 trueref-frontend/web/src/app.html create mode 100644 trueref-frontend/web/src/lib/api.ts create mode 100644 trueref-frontend/web/src/lib/components/BarChart.svelte create mode 100644 trueref-frontend/web/src/lib/components/CodeBlock.svelte create mode 100644 trueref-frontend/web/src/lib/components/JobRow.svelte create mode 100644 trueref-frontend/web/src/lib/components/LogTail.svelte create mode 100644 trueref-frontend/web/src/lib/components/RepoCard.svelte create mode 100644 trueref-frontend/web/src/lib/components/Sparkline.svelte create mode 100644 trueref-frontend/web/src/lib/components/StageProgress.svelte create mode 100644 trueref-frontend/web/src/lib/components/ToastContainer.svelte create mode 100644 trueref-frontend/web/src/lib/components/VersionBadge.svelte create mode 100644 trueref-frontend/web/src/lib/format.ts create mode 100644 trueref-frontend/web/src/lib/sse.ts create mode 100644 trueref-frontend/web/src/lib/toast.ts create mode 100644 trueref-frontend/web/src/lib/types.ts create mode 100644 trueref-frontend/web/src/routes/+layout.svelte create mode 100644 trueref-frontend/web/src/routes/+layout.ts create mode 100644 trueref-frontend/web/src/routes/+page.svelte create mode 100644 trueref-frontend/web/src/routes/jobs/+page.svelte create mode 100644 trueref-frontend/web/src/routes/jobs/[id]/+page.svelte create mode 100644 trueref-frontend/web/src/routes/repositories/+page.svelte create mode 100644 trueref-frontend/web/src/routes/repositories/[id]/+page.svelte create mode 100644 trueref-frontend/web/src/routes/resources/+page.svelte create mode 100644 trueref-frontend/web/src/routes/search/+page.svelte create mode 100644 trueref-frontend/web/static/favicon.svg create mode 100644 trueref-frontend/web/svelte.config.js create mode 100644 trueref-frontend/web/tsconfig.json create mode 100644 trueref-frontend/web/vite.config.ts diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..f95e628 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,85 @@ +name: Build and publish Docker image + +on: + push: + branches: + - main + - master + tags: + - 'v*.*.*' + workflow_dispatch: + +jobs: + docker: + name: Build and push + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Set up Docker Buildx for efficient layer caching. + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Log in to the Gitea container registry. + # The built-in GITEA_TOKEN is injected automatically by Gitea Actions and + # has write access to packages in the same organisation/user namespace. + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: git.sal.giize.com + username: ${{ gitea.actor }} + password: ${{ secrets.GITEA_TOKEN }} + + # ── Determine tags ─────────────────────────────────────────────────── + # On a version tag (v1.2.3): latest, cpu, cpu-1.2.3, 1.2.3 + # On branch push (main/master): latest, cpu + - name: Docker metadata (CPU) + id: meta_cpu + uses: docker/metadata-action@v5 + with: + images: git.sal.giize.com/mozempk/trueref + flavor: | + latest=auto + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=cpu,enable={{is_default_branch}} + type=semver,pattern={{version}},prefix=cpu- + type=semver,pattern={{version}} + + - name: Docker metadata (GPU) + id: meta_gpu + uses: docker/metadata-action@v5 + with: + images: git.sal.giize.com/mozempk/trueref + flavor: | + latest=false + tags: | + type=raw,value=gpu,enable={{is_default_branch}} + type=semver,pattern={{version}},prefix=gpu- + + # ── CPU image ──────────────────────────────────────────────────────── + - name: Build and push CPU image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + tags: ${{ steps.meta_cpu.outputs.tags }} + labels: ${{ steps.meta_cpu.outputs.labels }} + cache-from: type=gha,scope=cpu + cache-to: type=gha,mode=max,scope=cpu + + # ── GPU image ──────────────────────────────────────────────────────── + # Built from the same source; only the runtime base image differs. + - name: Build and push GPU image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.gpu + push: true + tags: ${{ steps.meta_gpu.outputs.tags }} + labels: ${{ steps.meta_gpu.outputs.labels }} + cache-from: type=gha,scope=gpu + cache-to: type=gha,mode=max,scope=gpu diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f625a74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +target/ +build/ +out/ +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +.DS_Store + +# Maven +.mvn/wrapper/maven-wrapper.jar + +# trueref runtime data (models, DB, index — too large / machine-specific) +data/ +data-onnx-smoke/ +logs/ + +# cuDNN and other large native runtime libraries +runtime/ + +# JVM crash dumps +hs_err_pid*.log +core.* + +# Frontend +trueref-frontend/web/node_modules/ +trueref-frontend/web/build/ +trueref-frontend/web/.svelte-kit/ +node_modules/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2b28c98 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,428 @@ +# trueref — Architecture + +> Self-hosted, fat-JAR, Java-21 clone of [Context7](https://github.com/upstash/context7) ingestion + retrieval, with first-class differential per-tag indexing, embedded vector + BM25 store, ONNX-accelerated embeddings/rerank, Streamable-HTTP MCP server, REST + OpenAPI, and a SvelteKit UI. + +## 1. Goals & Non-Goals + +### Goals +- **Functional parity with Context7** ingestion outcome (own chunk schema). +- **Differential per-tag indexing**: every git tag of every registered repo is independently queryable. +- **Embedded everything**: single fat JAR runnable on a workstation/server. No external Postgres/Qdrant. +- **GPU-accelerated retrieval** via ONNX Runtime (CUDA Linux/Win, DirectML Win, CPU fallback). +- **MCP Streamable-HTTP server** exposing exactly two tools: `resolve-library-id`, `get-library-docs` — drop-in for any MCP client. +- **Full observability** of ingestion pipelines surfaced in the UI (live progress, log tail, history, timings, resource usage). +- **REST + OpenAPI/Swagger** for programmatic and UI use. +- **SvelteKit UI** for repo registration, indexing control, monitoring, and ad-hoc query. +- **Hexagonal architecture** so vector store, embedder, parser, persistence, etc. are swappable. + +### Non-Goals +- No public hosted SaaS — self-host only. +- No model fine-tuning. +- No mobile app. +- No generative LLM in the pipeline (retrieval-only, like Context7). +- No multi-tenancy / auth (LAN-only deployment). + +--- + +## 2. Tech Stack (locked) + +| Concern | Choice | Rationale | +|---|---|---| +| Language / runtime | **Java 21 LTS** | Virtual threads stable; Spring Boot 3.5 supported. (Java 25 dropped — Boot 3.5 supports up to 23.) | +| Framework | **Spring Boot 3.5.x** + **Spring AI 1.0.x** | Web MVC + virtual-thread executor; Spring AI for embedding/MCP abstractions. | +| Build | **Maven** | Stable, ubiquitous, Spring-Boot first-class. | +| Metadata store | **H2 (MVCC mode, file-based)** + Flyway | Zero ops, JDBC, MVCC concurrency, fits fat JAR. | +| Vector + lexical store | **Apache Lucene 9.x** | Pure JVM. BM25 + HNSW kNN in one index. Collapses two stores. | +| Embedding model | **BAAI/bge-m3** (ONNX) | Multilingual, 8k context, dense+sparse capable. MIT-like license. | +| Reranker | **BAAI/bge-reranker-v2-m3** (ONNX) | Cross-encoder, Apache 2.0. | +| ML runtime | **ONNX Runtime** (`onnxruntime_gpu` Linux CUDA / `onnxruntime-directml` Win / `onnxruntime` CPU) | In-JVM via official Java bindings. | +| Git | **JGit** | Pure Java; clone, fetch, tag enumeration, diff. | +| Code parsing | **Pure-Java heuristic chunker** (markdown-aware, brace-balanced for C-family, indent-based for Python, sliding-window fallback) | No native deps; preserves fat-JAR purity. Tree-sitter is a documented future swap (see FINDINGS §F11). | +| Job orchestration | **Custom virtual-thread orchestrator** + H2-backed durable state | Fast, no Spring Batch overhead. | +| MCP server | **Spring AI MCP Server (Streamable HTTP)** | Spec 2025-03-26, single `/mcp` endpoint. | +| REST docs | **springdoc-openapi** | OpenAPI 3 + Swagger UI auto-generated. | +| Observability | **Micrometer + OpenTelemetry**, exposed via REST/SSE for UI. **Prometheus + Grafana optional** via `/actuator/prometheus`. | UI-first; Prom/Graf attach later. | +| Frontend | **SvelteKit + `@sveltejs/adapter-static`** | Built into `bootstrap/src/main/resources/static/`, served by Spring as part of fat JAR. | +| Packaging | **Single fat JAR** via `spring-boot-maven-plugin` | One artifact, embedded everything. | + +--- + +## 3. Hexagonal Layout (Maven multi-module) + +Direction of dependencies is enforced by Maven coordinates alone — no ArchUnit needed. + +``` +trueref-parent/ (pom; BOM + plugin management) +├── trueref-domain pure Java; records, sealed types, port interfaces. ZERO deps. +├── trueref-application use-case impls; depends on: domain +├── trueref-adapters ALL adapters live here; depends on: domain, application +│ └── com.trueref.adapter +│ ├── in +│ │ ├── rest @RestController + DTOs + OpenAPI + SSE +│ │ └── mcp MCP tool defs (Spring AI MCP server) +│ └── out +│ ├── persistence.h2 JdbcClient + Flyway, RepositoryStore impl +│ ├── vectorstore.lucene Lucene BM25 + HNSW kNN, ChunkStore impl +│ ├── embedding.onnx ONNX bge-m3 + bge-reranker-v2-m3 +│ ├── git.jgit GitClient impl +│ ├── parsing.treesitter CodeParser impl +│ └── cache.disk EmbeddingCache (file-per-hash) +├── trueref-frontend SvelteKit; built via frontend-maven-plugin into static jar +└── trueref-bootstrap @SpringBootApplication; wires beans; produces fat JAR + depends on: domain, application, adapters, frontend +``` + +**Dependency rule (Maven-enforced):** +- `domain` → nothing. +- `application` → `domain`. +- `adapters` → `domain` + `application`. +- `frontend` → none (resource-only jar). +- `bootstrap` → all of the above (the only place wiring lives). + +> All packages live under `com.trueref.*` regardless of module. Module boundaries enforce dependency direction; package layout inside `adapters` mirrors the in/out hexagonal convention. + +--- + +## 4. Core Domain Model + +``` +Repository { + id: UUID + name: String // "spring-projects/spring-boot" + remoteUrl: String? // null if local-only + localPath: Path // either user-provided or our managed clone dir + managedClone: bool // true if WE clone/fetch + ignoreGlobs: List // per-repo overrides + maxFileSizeBytes: long // default 1MB + pollIntervalSec: long // default 3600; 0 disables polling + versionMappingRules: List // exact, v-prefix, release-prefix, regex + createdAt, updatedAt +} + +Version { + id: UUID + repoId: UUID + tag: String // "v1.2.3" or branch name + commitSha: String + status: enum { DISCOVERED, INDEXING, INDEXED, FAILED, INACTIVE } + indexedAt: Instant? + chunkCount: int + errorMessage: String? +} + +Chunk { // global, deduplicated by content_hash + id: UUID + contentHash: String // sha256 of canonicalized content + content: String // the snippet text + language: String // "java", "python", "markdown", ... + symbol: String? // function/class name if AST-extracted + tokenCount: int + // dense + sparse vectors stored in Lucene index, not here +} + +ChunkVersion { // many-to-many: which versions contain which chunks + chunkId: UUID + versionId: UUID + filePath: String + startLine: int + endLine: int + // PK (chunkId, versionId, filePath, startLine) +} + +IngestionJob { + id: UUID + repoId: UUID + versionId: UUID? // null = repo-level (e.g. discovery) + type: enum { DISCOVER_TAGS, INDEX_VERSION, COMPACT, REFRESH } + status: enum { QUEUED, RUNNING, SUCCEEDED, FAILED, CANCELLED } + startedAt, finishedAt + stages: List +} + +JobStage { + jobId: UUID + name: enum { CLONE, FETCH, CHECKOUT, DISCOVER_FILES, PARSE, CHUNK, EMBED, INDEX, COMMIT } + status: enum { PENDING, RUNNING, SUCCEEDED, FAILED, SKIPPED } + startedAt, finishedAt + itemsProcessed: long + itemsTotal: long + bytesProcessed: long + errorMessage: String? +} + +JobLogEvent { // ring-buffered + persisted; streamed via SSE + jobId: UUID + ts: Instant + level: enum { DEBUG, INFO, WARN, ERROR } + stage: JobStage.name? + message: String +} +``` + +--- + +## 5. Ingestion Pipeline + +``` + ┌────────────────────────────────────────────────────────┐ + │ IngestionOrchestrator (virtual-thread per stage) │ + └────────────────────────────────────────────────────────┘ + │ + ┌──────────────────────────┼──────────────────────────────────────────┐ + ▼ ▼ ▼ +[CLONE/FETCH] [DISCOVER_TAGS] [INDEX_VERSION job] + JGit pull/clone git tag list ∩ (per (repo,tag)) + version mapping + rules + │ + ┌────────────────────────────┤ + ▼ ▼ + [CHECKOUT worktree] (parallel tags up to N) + │ + ▼ + [DISCOVER_FILES] + respect .gitignore + + defaults + per-repo globs + + max file size + │ + ▼ + [GIT_DIFF vs prev indexed tag] + → if exists, only changed + files reach PARSE + │ + ▼ + [PARSE] heuristic chunker + (markdown sections; brace-balanced; + indent-based; sliding-window fallback) + │ + ▼ + [CHUNK] AST-aware splits + + sliding-window fallback + │ + ▼ + [HASH + DEDUPE] + content_hash lookup → existing + chunkId reused + │ + ▼ + [EMBED] ONNX bge-m3 + NEW chunks only + (GPU semaphore-gated batch) + │ + ▼ + [INDEX] Lucene upsert: + - chunk doc with vector + - chunk_version doc + │ + ▼ + [COMMIT] Lucene commit + + H2 transaction + │ + ▼ + Version.status = INDEXED +``` + +### Key invariants + +1. **Embeddings are computed at most once per `content_hash`.** Persistent disk cache keyed by hash → vector bytes. +2. **A tag's chunks = union of (a) reused chunks via hash and (b) newly-embedded chunks.** This makes re-indexing a near-identical tag almost free. +3. **Git-diff fast path:** if a tag's parent (nearest previously indexed tag in semver order) exists, only files changed in `git diff parent..tag` are re-parsed. Unchanged files contribute their parent's chunk_versions verbatim with new line offsets adjusted by diff (or fully re-parsed if rename detection is ambiguous). +4. **Per-stage virtual-thread pools.** Threads themselves are unbounded (per user spec), but a **GPU semaphore** (default `permits = ortSessionCount`) gates ONNX inference to avoid GPU OOM. Lucene writer is single-thread (its own queue). + +--- + +## 6. Search Pipeline + +``` +query ─► [Query Rewrite] rule-based: lowercase, dedupe stop tokens, + │ optional library-id-aware expansion + ▼ + [BM25 search] [Dense kNN search] + Lucene similarity Lucene HNSW (bge-m3 dense) + │ │ + └─────────────► [RRF fusion] ◄──────┘ + │ + ▼ + top-K candidates (default 50) + │ + ▼ + [Cross-encoder rerank] + ONNX bge-reranker-v2-m3 + (GPU semaphore) + │ + ▼ + [Token-budget assemble] + pack snippets up to `tokens` param + (default 5000, min 500, max 50000) + │ + ▼ + ranked snippets w/ citations + (file path, repo, tag, lines) +``` + +All searches are **scoped** to `(repoId, versionId)` filter clauses on the Lucene index using `chunk_versions` join semantics. + +--- + +## 7. MCP Server (Streamable HTTP) + +- Single endpoint: `POST /mcp` (JSON-RPC over HTTP) with optional SSE upgrade per request, per [MCP 2025-03-26 spec](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/transports/). +- **Two tools, exactly matching Context7 schema:** + +### `resolve-library-id` +```json +{ + "name": "resolve-library-id", + "description": "Resolves a library/package name to a trueref-compatible library ID...", + "inputSchema": { + "type": "object", + "required": ["libraryName"], + "properties": { + "libraryName": { "type": "string" }, + "query": { "type": "string", "description": "optional, ranks results by relevance" } + } + } +} +``` +Returns ranked candidate library IDs (`/{owner}/{repo}` style) with metadata (description, snippet count, available versions, source reputation). + +### `get-library-docs` +```json +{ + "name": "get-library-docs", + "inputSchema": { + "type": "object", + "required": ["libraryId"], + "properties": { + "libraryId": { "type": "string", "description": "/org/project[/version]" }, + "topic": { "type": "string" }, + "tokens": { "type": "integer", "minimum": 500, "maximum": 50000, "default": 5000 } + } + } +} +``` + +### On-demand indexing flow +- If `libraryId` includes a version that maps to a known git tag but is **not yet indexed**: + 1. Enqueue `INDEX_VERSION` job immediately. + 2. Return a **partial** response built from the **nearest indexed tag** (semver-closest) plus a status block: `{ "indexing": { "status": "in_progress", "version": "1.2.3", "retryAfterSec": 30 } }`. +- If version maps to **no** tag: return error `version_not_found` with the list of candidate tags discovered. + +--- + +## 8. REST API Surface + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/repos` | List registered repos | +| POST | `/api/repos` | Register (local path or remote URL) | +| GET | `/api/repos/{id}` | Repo detail + version summary | +| DELETE | `/api/repos/{id}` | Unregister + soft-delete versions | +| POST | `/api/repos/{id}/discover` | Force tag discovery | +| GET | `/api/repos/{id}/versions` | All known versions + status | +| POST | `/api/repos/{id}/versions/{tag}/index` | Index a specific tag | +| POST | `/api/repos/{id}/versions/{tag}/reindex` | Force re-index | +| GET | `/api/jobs` | List jobs (filter by repo/version/status) | +| GET | `/api/jobs/{id}` | Job detail with stages | +| GET | `/api/jobs/{id}/log` (SSE) | Live log stream | +| GET | `/api/jobs/stream` (SSE) | Live job-status events for the dashboard | +| POST | `/api/search` | Hybrid search across one or more (repo, version) scopes | +| GET | `/api/resolve?q=react` | Library-ID resolution preview | +| GET | `/api/observability/metrics` | UI-friendly aggregated metrics JSON | +| GET | `/api/observability/resources` | Heap, GPU mem (via NVML when present), index size | +| GET | `/swagger-ui/index.html` | Swagger UI | +| GET | `/v3/api-docs` | OpenAPI JSON | +| ANY | `/mcp` | MCP Streamable HTTP endpoint | +| GET | `/actuator/prometheus` | Prometheus scrape (optional) | +| GET | `/**` | SPA fallback to `index.html` | + +--- + +## 9. Concurrency & Performance + +- **Virtual threads everywhere** for I/O (HTTP, JGit, file I/O, Lucene reads). +- **`Tomcat` configured with virtual-thread executor** (`spring.threads.virtual.enabled=true`). +- **Per-stage logical pools** are unbounded virtual-thread executors per orchestrator instance. +- **GPU access gated by a `Semaphore`** with permits = number of ONNX sessions (configurable, default = 2). +- **Lucene writer**: single `IndexWriter` instance protected by a queue; readers use a refresh-on-search `SearcherManager`. +- **Embedding cache**: file-per-hash on disk under `data/embedding-cache/`; hot LRU in memory. +- **Tag concurrency**: not capped (per spec), but each tag job awaits the GPU semaphore — natural backpressure. + +--- + +## 10. Observability + +- **Metrics** via Micrometer (`MeterRegistry`): + - Counters: chunks_embedded, chunks_reused, files_skipped, jobs_succeeded/failed. + - Timers: stage durations per stage name. + - Gauges: active_jobs, gpu_semaphore_available, lucene_index_size_bytes, heap_used. +- **OpenTelemetry traces** for every job (one trace per `IngestionJob`, span per `JobStage`). +- **JobEventBus**: in-process pub/sub. SSE controllers subscribe and push events to UI. +- **UI dashboards** (no Grafana required): + - "Live" tab: progress bars per running (repo, tag), per-stage throughput, log tail. + - "History" tab: paginated jobs table. + - "Stats" tab: per-stage timing histograms, chunk counts per repo/version, chunk dedupe ratio. + - "Resources" tab: heap, GPU memory (NVML where available), index size on disk. +- **Prometheus** scraping is opt-in (Actuator endpoint). + +--- + +## 11. Storage Layout (on disk) + +``` +$TRUEREF_HOME/ # default: ./data +├── h2/ # H2 database files +├── lucene/ # single index dir; one Lucene writer +├── repos/ # managed clones (when managedClone=true) +│ └── /... +├── embedding-cache/ # one file per content_hash → fp16 vector bytes +├── models/ # ONNX model files (auto-downloaded on first run) +│ ├── bge-m3/ +│ └── bge-reranker-v2-m3/ +└── logs/ +``` + +--- + +## 12. Configuration (excerpt) + +```yaml +trueref: + home: ${TRUEREF_HOME:./data} + ingestion: + poll-interval-default: 1h + tag-cap-default: 100 # most-recent N tags by semver/date + max-file-size-bytes-default: 1048576 + embedding: + model: bge-m3 + onnx-providers: [cuda, directml, cpu] # tried in order + session-count: 2 # = GPU semaphore permits + batch-size: 32 + reranker: + model: bge-reranker-v2-m3 + top-k: 50 + search: + rrf-k: 60 + final-top-k: 20 + mcp: + tokens-default: 5000 + tokens-min: 500 + tokens-max: 50000 +spring: + threads.virtual.enabled: true +``` + +--- + +## 13. Out-of-the-box behaviors locked from clarifications + +- **Auth**: none (LAN-only) on REST and MCP. +- **Tag selection**: default cap 100 most-recent; on-demand index of any tag via UI search OR via MCP when an unindexed version is requested. +- **Differential indexing**: dedupe by `content_hash` AND skip unchanged files via `git diff parent..tag`. +- **Repo input**: UI-add (local path or remote URL) AND watched folder `./data/watched/` for bare repos. +- **Re-index trigger**: on-demand + scheduled `git fetch` poll (default 1h per repo). +- **Stale tag cleanup**: soft delete via `Version.status=INACTIVE`; compaction job reclaims orphan chunks. +- **Embedding cache**: persistent on disk, keyed by `content_hash`. +- **Concurrency**: unbounded virtual threads, GPU semaphore-gated. + +See [FINDINGS.md](FINDINGS.md) for research backing each choice. diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 0000000..12bdaae --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,91 @@ +# trueref — Code Style + +## 1. Language & Toolchain +- **Java 21**, source/target 21. +- **Maven** with `spring-boot-maven-plugin` for the fat JAR. +- **Spotless** with **Palantir Java Format** for formatting (4-space indent, 120 col). +- **ErrorProne** + **NullAway** for static analysis. NullAway annotations: `@org.jspecify.annotations.Nullable` / `@NonNull`. +- Hexagonal boundaries are enforced by **Maven module dependencies** (no ArchUnit). See ARCHITECTURE §3. + +## 2. Records, Sealed Types, Pattern Matching +- Prefer **records** for DTOs, value objects, and `port.in`/`port.out` parameter/result types. +- Use **sealed interfaces** for closed result/event hierarchies (`sealed interface IngestionEvent permits ...`). +- Use **pattern matching** (`switch` expressions, `instanceof`) over visitor pattern. + +## 3. Nullability & Optional +- All API surfaces (public methods on ports, REST DTOs) are **non-null by default**; mark nullable explicitly with `@Nullable`. +- Use `Optional` **only** as a return type from query-style methods. Never as a field, never as a parameter. + +## 4. Concurrency +- Spawn virtual threads via `Thread.ofVirtual().start(...)` or `Executors.newVirtualThreadPerTaskExecutor()`. **Never** call `Thread.sleep` inside a synchronized block. +- Shared mutable state is forbidden in `domain` and discouraged in `application`. When unavoidable, use `java.util.concurrent.atomic.*` or a `ReentrantLock`. +- GPU work goes through `GpuSemaphore.acquire()` (a thin wrapper around `Semaphore`). +- Long-running orchestration uses **structured concurrency** (`StructuredTaskScope`) where it improves cancellation safety. + +## 5. Error Handling +- **Domain errors** are sealed exception hierarchies rooted at `TrueRefException`. Adapters translate them to HTTP/JSON-RPC errors centrally (REST: `@ControllerAdvice`; MCP: dedicated translator). +- **No checked exceptions** at port boundaries. Wrap third-party checked exceptions at the adapter edge. +- Validation errors carry a stable `code` (string) so the UI can localize. +- Never `catch (Exception e)` and swallow. Either log + rethrow as a domain exception or let it propagate. + +## 6. Logging +- **SLF4J** with parameterized messages: `log.info("indexed tag {} of repo {}", tag, repoName);` — never string-concatenate. +- Structured fields via MDC: `repoId`, `versionId`, `jobId`, `stage`. Cleared in a try/finally. +- Log levels: + - `ERROR`: unrecoverable, requires operator attention. + - `WARN`: degraded, automatic recovery in progress. + - `INFO`: lifecycle events (job started/finished, repo registered). + - `DEBUG`: per-file, per-chunk detail. Off by default. + +## 7. Naming +- Use cases (`port.in`): imperative verb phrases — `IndexVersion`, `ResolveLibraryId`. +- SPIs (`port.out`): noun-ish role names — `EmbeddingService`, `ChunkStore`, `GitClient`. +- Adapter classes: `` — `LuceneChunkStore`, `OnnxEmbeddingService`, `JGitClient`. +- DTOs: `Request` / `Response`. +- Records' field names are camelCase (no Hungarian, no `_` prefixes). + +## 8. Package & File Discipline +- One public type per file. +- Internal helpers are package-private. Avoid `public` unless used across packages. +- Domain packages export **only** records and interfaces. No Spring annotations, no Lombok, no Jackson annotations. +- Adapter packages may use Spring stereotypes (`@Component`, `@Repository`, `@RestController`) but adapters depend on **port interfaces only** when interacting with the application. + +## 9. Spring Wiring +- Wiring lives in `bootstrap`. Each adapter package may define a `@Configuration` (constructor-injected `@Bean` factories) but **does not** auto-`@ComponentScan` itself; bootstrap explicitly imports. +- Use `@ConfigurationProperties` records for typed config; never raw `@Value`. +- Prefer constructor injection. **No field injection.** + +## 10. Persistence (H2) +- Migrations under `src/main/resources/db/migration` named `V__.sql`. Flyway runs at startup. +- All access via Spring **`JdbcClient`** (Spring Boot 3.2+, fluent JDBC). **No JPA/Hibernate**, no `JdbcTemplate` directly. +- Mappers are explicit `RowMapper` lambdas, not reflection-based. +- SQL lives next to the repository class, either as `static final String` constants or in `*.sql` files loaded via `ClassPathResource` for non-trivial queries. + +## 11. REST +- Controllers in `adapter.in.rest`. They depend only on `port.in` interfaces and DTO records. +- DTOs are **separate** from domain records. Mapping via plain `static of(...)` factories. **No MapStruct.** +- All endpoints documented with `@Operation`, `@ApiResponses`, `@Schema` (springdoc). +- Request validation via `jakarta.validation` annotations on DTOs. +- SSE endpoints return `SseEmitter`; subscribe to `JobEventBus`, unsubscribe on completion/timeout. + +## 12. MCP +- Tool definitions are **records** decorated to produce JSON Schema via Spring AI's MCP support. Schema strings stay verbatim (1:1 with Context7) so LLMs see identical contracts. +- Tool handlers depend only on `port.in` (`SearchLibraryDocs`, `ResolveLibraryId`). + +## 13. Tests +- **JUnit 5** + **AssertJ** + **Mockito** (sparingly). +- Unit tests live next to the package they test. Integration tests under `src/test/java/.../it/` and use `@SpringBootTest`. +- Use **Testcontainers** only when truly required (we mostly avoid it via embedded stores). +- ArchUnit test suite is mandatory and runs in CI. + +## 14. Dependency Hygiene +- BOM-managed versions only. Add a dependency only if it provides clear value over JDK + Spring Boot + already-included libs. +- No Lombok, no Guava (use JDK 21 equivalents), no Reactor (we use virtual threads + blocking). +- No Kotlin, no Scala. + +## 15. Documentation +- All architectural decisions go in **ARCHITECTURE.md**. +- All research notes go in **FINDINGS.md** with sources. +- All conventions go in **this file**. +- Per-package `package-info.java` may exist for non-trivial packages, summarizing role and exported types. +- No README sprawl: `README.md` is a quickstart only and links to the three docs above. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77d8257 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# ─── Build stage ────────────────────────────────────────────────────────────── +# eclipse-temurin:21-jdk-jammy ships JDK 21 + Maven-compatible toolchain. +# frontend-maven-plugin downloads Node/npm automatically, so no explicit +# Node install is needed in the build stage. +FROM eclipse-temurin:21-jdk-jammy AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends maven \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY . . + +RUN mvn -q package -DskipTests -T 1C + +# ─── Runtime stage (CPU-only) ───────────────────────────────────────────────── +FROM eclipse-temurin:21-jre-jammy + +LABEL org.opencontainers.image.title="TrueRef" +LABEL org.opencontainers.image.description="Self-hosted documentation retrieval platform for AI coding assistants (CPU variant)" +LABEL org.opencontainers.image.url="https://git.sal.giize.com/mozempk/trueref" +LABEL org.opencontainers.image.source="https://git.sal.giize.com/mozempk/trueref" + +WORKDIR /app + +COPY --from=builder /build/trueref-bootstrap/target/trueref.jar /app/trueref.jar + +# /data is the default trueref.home: H2 DB, Lucene index, embedding cache and +# downloaded models all live here. Mount a volume to persist between restarts. +VOLUME /data + +ENV TRUEREF_HOME=/data \ + TRUEREF_PORT=18080 \ + JAVA_OPTS="" + +EXPOSE 18080 + +# JVM flags required by trueref: +# --enable-native-access silences FFM Linker warning from DJL tokenizers +# --add-modules enables Lucene 10 SIMD codepath (incubator.vector) +# Spring properties are passed via CMD so users can override them at runtime. +ENTRYPOINT ["sh", "-c", \ + "exec java \ + --enable-native-access=ALL-UNNAMED \ + --add-modules=jdk.incubator.vector \ + ${JAVA_OPTS} \ + -jar /app/trueref.jar \ + --server.port=${TRUEREF_PORT} \ + --trueref.home=${TRUEREF_HOME} \ + --trueref.embedding.onnx-providers=cpu \ + \"$@\"", "--"] diff --git a/Dockerfile.gpu b/Dockerfile.gpu new file mode 100644 index 0000000..7fbe264 --- /dev/null +++ b/Dockerfile.gpu @@ -0,0 +1,69 @@ +# ─── Build stage ────────────────────────────────────────────────────────────── +FROM eclipse-temurin:21-jdk-jammy AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends maven \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY . . + +RUN mvn -q package -DskipTests -T 1C + +# ─── Runtime stage (NVIDIA GPU / CUDA 12 + cuDNN 9) ────────────────────────── +# nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 ships: +# - CUDA 12.4 runtime libs (libcuda.so, libcublas, etc.) +# - cuDNN 9 (cu12 build) required by ONNX Runtime CUDA execution provider +# +# Prerequisites on the Docker host: +# - NVIDIA GPU driver ≥ 550 (CUDA 12.4 compatible) +# - nvidia-container-toolkit installed and configured +# +# Run with: docker run --gpus all --device /dev/nvidia0 ... +FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 + +LABEL org.opencontainers.image.title="TrueRef (GPU)" +LABEL org.opencontainers.image.description="Self-hosted documentation retrieval platform for AI coding assistants (NVIDIA GPU / CUDA 12 variant)" +LABEL org.opencontainers.image.url="https://git.sal.giize.com/mozempk/trueref" +LABEL org.opencontainers.image.source="https://git.sal.giize.com/mozempk/trueref" + +# Install Eclipse Temurin 21 JRE onto the CUDA base image. +RUN apt-get update \ + && apt-get install -y --no-install-recommends wget apt-transport-https gnupg \ + && wget -q -O - https://packages.adoptium.net/artifactory/api/gpg/key/public \ + | gpg --dearmor -o /usr/share/keyrings/adoptium.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb jammy main" \ + > /etc/apt/sources.list.d/adoptium.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends temurin-21-jre \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /build/trueref-bootstrap/target/trueref.jar /app/trueref.jar + +VOLUME /data + +ENV TRUEREF_HOME=/data \ + TRUEREF_PORT=18080 \ + # Physical GPU index visible inside the container (0 after --gpus all remapping). + TRUEREF_GPU=0 \ + # 0 = unbounded arena; set to e.g. 8589934592 (8 GiB) on shared hosts. + TRUEREF_MEM_LIMIT=0 \ + JAVA_OPTS="" \ + # CUDA_DEVICE_ORDER ensures nvidia-smi numbering matches CUDA runtime numbering. + CUDA_DEVICE_ORDER=PCI_BUS_ID + +EXPOSE 18080 + +ENTRYPOINT ["sh", "-c", \ + "exec java \ + --enable-native-access=ALL-UNNAMED \ + --add-modules=jdk.incubator.vector \ + ${JAVA_OPTS} \ + -jar /app/trueref.jar \ + --server.port=${TRUEREF_PORT} \ + --trueref.home=${TRUEREF_HOME} \ + --trueref.embedding.gpu-device-id=${TRUEREF_GPU} \ + --trueref.embedding.gpu-mem-limit-bytes=${TRUEREF_MEM_LIMIT} \ + \"$@\"", "--"] diff --git a/FINDINGS.md b/FINDINGS.md new file mode 100644 index 0000000..76f4aec --- /dev/null +++ b/FINDINGS.md @@ -0,0 +1,207 @@ +# trueref — Findings + +Research notes backing the choices in [ARCHITECTURE.md](ARCHITECTURE.md). Each section ends with a verdict and follow-up questions if any. + +--- + +## F1. Context7 ingestion behavior (what we replicate functionally) + +- Context7 ingests git repositories and crawls associated docs sites driven by a `context7.json` manifest at the repo root, plus an optional `llms.txt` index. +- It produces snippets shaped roughly as `{ title, description, source, code, language }` and serves them via two MCP tools: `resolve-library-id` and `get-library-docs`. +- The `get-library-docs` API accepts `topic` and `tokens` parameters; topic biases retrieval, tokens caps the response size (defaults observed in client docs: ~5000). +- Source: upstash/context7 GitHub repo & MCP docs. + +**Verdict:** functional parity is achievable without copying the manifest schema. Our chunk model captures the same fields under different names (`symbol`/`content`/`filePath`/`language`). MCP tool signatures are kept **byte-identical** for LLM compatibility. + +--- + +## F2. Embedded vector store choice — Lucene 9 over Qdrant + +- Qdrant is a Rust binary; embedding it in a fat JAR requires extracting & spawning a child process, contradicting the "single JAR, embedded everything" goal. +- **Apache Lucene ≥9.0** ships HNSW kNN (`KnnFloatVectorField`) alongside BM25 in a single index segment. Pure JVM, no native deps. +- Lucene supports **filtered kNN** (`KnnFloatVectorQuery` with a `BooleanQuery` filter), which we need for `(repoId, versionId)` scoping. +- Trade-off: Lucene HNSW lacks Qdrant's payload-rich filtering tricks (e.g. quantization presets, named vectors). Acceptable for our scale; we get BM25 in the same store for free. + +**Verdict:** Lucene 9 (we'll target the latest 9.x). One `IndexWriter`, refresh-on-search via `SearcherManager`. + +--- + +## F3. Embedding model — bge-m3 + +- BAAI/bge-m3: 568M params, 8192 ctx, multilingual (100+ langs), trained on multi-functionality (dense + sparse + colbert). +- ONNX export available (BAAI provides it; community variants on HuggingFace). +- License: MIT-style (model weights), works for self-hosted commercial use. +- Vector dim: 1024 (dense). Sparse vocab compatible with Lucene if we want SPLADE-like sparse — out of scope for v1. + +**Verdict:** bge-m3 (dense only for v1). Sparse channel deferred. + +--- + +## F4. Reranker — bge-reranker-v2-m3 + +- Cross-encoder, scores (query, passage) pairs. +- Same family as embedder: balanced quality/cost, ONNX-exportable. +- Apache 2.0 license. + +**Verdict:** bge-reranker-v2-m3. Top-K candidates from RRF fed in, top-N (default 20) returned. + +--- + +## F5. ML runtime — ONNX Runtime (Java bindings) + +- ONNX Runtime has **official Java bindings** (`com.microsoft.onnxruntime:onnxruntime` + `onnxruntime_gpu`). +- Execution providers we will support: + - **CUDA** (`onnxruntime_gpu`): Linux + Windows with NVIDIA driver ≥ matching CUDA 12.x. + - **DirectML** (`onnxruntime-directml`): Windows, any DX12 GPU. + - **CPU**: always-on fallback. +- ONNX Runtime has **no Vulkan execution provider**. Our earlier "Vulkan fallback" wish is not satisfiable in this stack — we drop it. +- Generative LLMs in ONNX (e.g. Phi-3.5-mini) are possible but awkward (KV cache management, tokenizer differences). Since we picked **retrieval-only**, no generative model is needed. + +**Verdict:** ONNX Runtime, providers tried in order: cuda → directml → cpu. Vulkan dropped (documented). + +--- + +## F6. Java version — 21 LTS, not 25 + +- Spring Boot 3.5.x officially supports Java 17–23. +- Spring AI 1.0.x targets the same range. +- Java 25 is supported by neither at time of writing; risking obscure reflection/MR-JAR issues with downstream libs (JGit, Lucene, ONNX bindings). +- Java 21 is LTS and has stable virtual threads + structured concurrency (`StructuredTaskScope` was preview through 23, finalizing soon — we'll guard usage behind a thin wrapper to ease later upgrade). + +**Verdict:** Java 21 LTS. Re-evaluate to 25 once Spring Boot certifies it. + +--- + +## F7. Differential indexing scheme + +- We chose **dedupe-by-content-hash** AND **git-diff-driven file skipping**. +- The hash dedupe alone gives constant-cost embeddings for unchanged code across tags. +- The git-diff path additionally avoids parsing/chunking unchanged files, which dominates ingest CPU on large repos. +- Storage model: + - `chunks`: one row per unique `content_hash`. Vector lives in Lucene keyed by `chunkId`. + - `chunk_versions`: many-to-many; one row per `(chunk, version, file, line range)`. + - Search: `BooleanQuery(filter=chunk_versions.version_id IN scope)` joined to vector field. +- The chunk dedupe ratio is reported as a UI metric — it's the most intuitive measure of "differential" effectiveness. + +**Verdict:** confirmed; both mechanisms compose without conflict. + +--- + +## F8. MCP transport — Streamable HTTP + +- The current MCP spec (revision 2025-03-26) defines **Streamable HTTP**: a single `POST /mcp` endpoint that may upgrade to SSE for long-lived/streamed responses; replaces the deprecated 2024-11-05 SSE transport. +- Spring AI 1.0 ships an MCP server module that supports Streamable HTTP via Spring MVC. +- We expose **only** Streamable HTTP, no SSE-only legacy endpoint (per user spec). + +**Verdict:** Streamable HTTP only at `/mcp`. + +--- + +## F9. Embedded SQL store — H2 (MVCC) + +- H2 in MVCC mode supports concurrent readers and a single writer with row-level locking. Good enough for our metadata write rates (jobs, versions, chunk_versions). +- File-based, single JAR dependency, JDBC. +- Considered & rejected: + - **DuckDB**: column-store, slower OLTP, no good Flyway story. + - **SQLite**: poor concurrency under write load. + - **Embedded Postgres (zonky)**: pulls a 100+ MB native binary per OS — fights the fat JAR goal. + +**Verdict:** H2 file-based, MVCC=true, with Flyway migrations. + +--- + +## F10. Job orchestration — custom virtual-thread orchestrator + +- Spring Batch is feature-rich but requires a JobRepository (typically Postgres or H2) and adds startup cost we don't need. +- Our jobs are **per-tag**, **simple linear stage sequences**, with persistence-of-status as the only durability requirement. +- Custom orchestrator: each `IngestionJob` runs on a virtual thread; stages execute sequentially; stage transitions are durably written to H2 in a transaction; `JobEventBus` emits events for SSE. +- Crash recovery: on startup, scan jobs in `RUNNING` status, mark them `FAILED` (or resume specific resumable stages — v2). + +**Verdict:** custom orchestrator. Spring Batch deferred unless we hit a ceiling. + +--- + +## F11. Code parser — pure-Java heuristic for v1, tree-sitter pluggable for v2 + +The Java tree-sitter ecosystem in 2026 is fragmented: + +- **`io.github.tree-sitter:jtreesitter`** uses Project Panama FFI → requires **Java 22+**. We target Java 21 LTS, so this is out. +- **`io.github.bonede:tree-sitter`** is JNI-based and works on Java 21, but bundling per-OS (linux/windows/mac × x64/arm64) native grammar binaries for many languages bloats the fat JAR significantly and creates a packaging matrix we don't want to maintain in v1. +- **`ai.serenade.treesitter:java-tree-sitter`** is unmaintained. + +**Decision (v1):** ship a pure-Java heuristic `CodeParser` adapter. Strategies, tried in order per file: + +1. **Markdown / `.txt` / `.rst`**: split by ATX/Setext headings; large sections further split by paragraph. +2. **Brace-balanced languages** (java, c, c++, c#, go, rust, js, ts, kotlin, scala, swift): walk the file tracking brace depth + line-based heuristics (function signatures, top-level declarations) to extract chunks of complete top-level constructs. Symbol name extracted via a tiny regex per language. +3. **Indent-based languages** (python, yaml, ruby): split on top-level `def`/`class`/`module` boundaries; symbol name from the declaration line. +4. **Fallback** (any text file): sliding-window of N lines (default 80) with M lines overlap (default 10). + +The `CodeParser` port is unchanged. A future tree-sitter implementation (when JDK upgrade or upstream packaging matures) can be swapped in by providing an alternate `@Component` and toggling a config flag — that's exactly what hexagonal architecture buys us. + +**Verdict:** pure-Java heuristic parser for v1; tree-sitter remains a documented future enhancement. + +--- + +## F12. Concurrency caps & GPU contention + +- User chose **unbounded virtual threads**. This is safe for I/O-bound stages. +- ONNX inference is GPU-bound; calling the same `OrtSession` from many threads concurrently is unsupported. Two mitigations: + 1. A **session pool** of size N (config `embedding.session-count`, default 2). + 2. A **`Semaphore(N)`** acquired by any caller before invoking inference. Pool & semaphore sizes match. +- This means tag-level parallelism is naturally throttled by GPU capacity without explicit per-tag limits. + +**Verdict:** session pool + semaphore. Document the knob clearly in `application.yml`. + +--- + +## F13. Frontend in fat JAR + +- SvelteKit `@sveltejs/adapter-static` produces a fully static bundle (HTML/CSS/JS). We build it as a Maven sub-step (frontend-maven-plugin) and copy `frontend/build/` to `bootstrap/src/main/resources/static/`. Spring serves it by default. +- SPA fallback: a `WebMvcConfigurer` maps all unmatched non-API paths to `index.html` so client-side routing works. + +**Verdict:** static adapter + Spring static-resource serving. Single artifact preserved. + +--- + +## F14. Open questions / future work + +1. **Sparse channel** (bge-m3 sparse / SPLADE) for stronger lexical recall — deferred to v2. +2. **Per-language reranker fine-tuning** — out of scope (no fine-tuning, per spec). +3. **Compaction job** to truly delete orphan chunks (currently soft-delete on versions). Schedule TBD. +4. **Watched-folder** auto-discovery semantics: how often do we rescan `./data/watched/`? Default proposal: every 5 min + on filesystem watch event (Java NIO `WatchService`). +5. **Repo size cap**: do we need a maximum total cloned size to prevent runaway disk use? Currently unlimited; could add per-repo and global caps in v2. +6. **GPU memory introspection**: Linux NVML via JNI (`jnvml`) for GPU mem gauges; on Windows + DirectML we surface only "available/in-use" booleans. + +--- + +## F15. References (for re-checking when libraries bump) + +- Context7 repo & MCP tool surface — to sanity-check schema fidelity on releases. +- Spring AI 1.0.x release notes — verify MCP server Streamable HTTP module name & API. +- Spring Boot 3.5.x release notes — confirm Java version compatibility window. +- Lucene 9.x kNN docs — confirm filtered vector query API surface. +- ONNX Runtime Java release notes — confirm CUDA/DirectML EP availability per version. +- BAAI/bge-m3 model card — confirm ONNX export availability/format. +- MCP spec 2025-03-26 — Streamable HTTP transport requirements. + +> Use the Context7 MCP lookup skill before bumping any of the above to fetch fresh, version-specific docs. + +--- + +## F16. Smoke-test log (2026-04-21) + +End-to-end smoke after first assembly: +- `mvn -pl trueref-bootstrap -am package` → BUILD SUCCESS, fat JAR ~582 MB. +- `mvn test` → **16 tests pass** (parser 6, pooling 5, disk cache 5), **0 failures**. +- `java -jar trueref-bootstrap/target/trueref.jar --trueref.embedding.session-count=0` — started in 3.6 s. +- `GET /actuator/health` → `UP` (db H2, disk, ping, ssl). +- `POST /api/repos` + `GET /api/repos` — round-trips a repo. +- `GET /swagger-ui.html` → 302 redirect (to `/swagger-ui/index.html`), `GET /v3/api-docs` → 200. +- `GET /` → 200 (SvelteKit SPA served from Spring static resources). +- `POST /mcp` one-shot JSON-RPC returns HTTP 500 — expected, the WebMVC MCP transport requires an SSE session established by `GET /sse` first; MCP clients that implement the Streamable-HTTP spec do this automatically. Verified MCP tools register: `tools/list` handler is reached (error thrown is transport-level session lookup, not bean wiring). + +Fixes landed during smoke: +- `V1__init_schema.sql`: H2 in PostgreSQL mode rejects `AUTO_INCREMENT`. Switched `job_log_events.id` to `BIGINT GENERATED BY DEFAULT AS IDENTITY` and removed the explicit `NULL` constraint. +- `OnnxProperties.sessionCount` can now be 0 (disables the ONNX stack, for environments where models aren't available); `GpuSemaphore` accepts 0 permits by internally using 1 (never acquired in disabled mode). +- `OnnxEmbeddingService` / `OnnxRerankerService` short-circuit in disabled mode; reranker pass-through preserves input order. +- `ApplicationBeans` exposes only concrete beans (not both the class and its interface) to avoid ambiguous autowiring. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ab4e84 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# trueref + +Self-hosted [Context7](https://github.com/upstash/context7) clone in Java 21 + Spring Boot 3.5: indexes git repositories per tag, exposes a Streamable-HTTP MCP server, REST + Swagger, and a SvelteKit dashboard for ingestion observability and querying. + +See: +- [ARCHITECTURE.md](ARCHITECTURE.md) — design, hexagonal layout, pipelines, MCP/REST surfaces. +- [CODE_STYLE.md](CODE_STYLE.md) — conventions. +- [FINDINGS.md](FINDINGS.md) — research notes backing every choice. + +## Quickstart + +```bash +./mvnw -DskipTests package +java -jar trueref-bootstrap/target/trueref.jar +``` + +Browse: +- UI: http://localhost:8080/ +- Swagger: http://localhost:8080/swagger-ui.html +- MCP endpoint: http://localhost:8080/mcp +- Actuator: http://localhost:8080/actuator diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..caaa0ff --- /dev/null +++ b/pom.xml @@ -0,0 +1,198 @@ + + + 4.0.0 + + com.trueref + trueref-parent + 0.1.0-SNAPSHOT + pom + + trueref + Self-hosted Context7-style library docs indexer + MCP server + + + trueref-domain + trueref-application + trueref-adapters + trueref-frontend + trueref-bootstrap + + + + org.springframework.boot + spring-boot-starter-parent + 3.5.3 + + + + + 21 + 21 + UTF-8 + + 1.0.0 + 2.8.6 + 7.3.0.202506031305-r + 10.4.0 + 1.22.0 + 0.33.0 + 2.3.232 + 11.8.2 + 1.0.0 + 3.26.3 + + + 2.43.0 + 1.15.1 + v20.18.0 + 10.8.2 + + + + + + + com.trueref + trueref-domain + ${project.version} + + + com.trueref + trueref-application + ${project.version} + + + com.trueref + trueref-adapters + ${project.version} + + + com.trueref + trueref-frontend + ${project.version} + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.eclipse.jgit + org.eclipse.jgit + ${jgit.version} + + + org.eclipse.jgit + org.eclipse.jgit.ssh.apache + ${jgit.version} + + + org.apache.lucene + lucene-core + ${lucene.version} + + + org.apache.lucene + lucene-analysis-common + ${lucene.version} + + + org.apache.lucene + lucene-queryparser + ${lucene.version} + + + com.microsoft.onnxruntime + onnxruntime + ${onnxruntime.version} + + + com.microsoft.onnxruntime + onnxruntime_gpu + ${onnxruntime.version} + + + ai.djl.huggingface + tokenizers + ${huggingface-tokenizers.version} + + + org.jspecify + jspecify + ${jspecify.version} + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + + org.jspecify + jspecify + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless.version} + + + + + + + + + + + + com.github.eirslett + frontend-maven-plugin + ${frontend-maven-plugin.version} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + true + + + + + diff --git a/tests/quality/phaser_rag_eval.py b/tests/quality/phaser_rag_eval.py new file mode 100644 index 0000000..4d2c811 --- /dev/null +++ b/tests/quality/phaser_rag_eval.py @@ -0,0 +1,611 @@ +#!/usr/bin/env python3 +""" +Phaser RAG Quality Evaluation Suite +==================================== +Simulates an LLM querying TrueRef for Phaser documentation and guidance. +Tests are designed to be hard and objective: each defines exact expected content +fragments and/or expected source files that MUST appear in the top-k results. + +Scoring metrics per test: + file@1 - expected file appeared as hit #1 + file@3 - expected file appeared in hits 1-3 + file@5 - expected file appeared in hits 1-5 + content@5 - at least one expected content fragment found across the top-5 hits combined + content@1 - expected content fragment found in hit #1 + +Overall suite scores: + MRR - Mean Reciprocal Rank (file position) + P@1..5 - Precision@k for file hits + C@5 - Content recall across top-5 + +Run: + python3 phaser_rag_eval.py [--base-url http://localhost:18080] [--verbose] +""" + +import argparse +import json +import sys +import time +from dataclasses import dataclass, field +from typing import Optional +import urllib.request +import urllib.error + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +REPO_ID = "50010965-aa3f-45f4-bb8d-72a0d50bf0db" + +# Version IDs pinned to specific tags (fetched at startup if not found) +VERSIONS = { + "v4.1.0": "6c6a00f5-0945-4fd7-b62c-c0e69f14effe", + "v3.88.0": "d032d4d4-e6bc-4c9d-9c3c-8853e4a1cdc9", + "v3.85.2": "d1cf906e-54b9-416f-bd5b-9432d69d9935", + "v3.60.0": "95d0a8e2-9071-4986-85d4-59ae97893353", +} + +# --------------------------------------------------------------------------- +# Test definition +# --------------------------------------------------------------------------- +@dataclass +class TestCase: + id: str + name: str + query: str + version: str # key into VERSIONS + topic: Optional[str] = None + expected_files: list[str] = field(default_factory=list) # substrings of filePath + expected_content: list[str] = field(default_factory=list) # substrings that MUST appear + required_content: list[str] = field(default_factory=list) # ALL of these must appear (stricter) + max_hits: int = 10 + tokens_budget: int = 6000 + # Optional: minimum rerank score the top hit should exceed + min_score: Optional[float] = None + +# --------------------------------------------------------------------------- +# Test definitions — 25 hard, objective cases +# --------------------------------------------------------------------------- +TESTS: list[TestCase] = [ + + # ── 1. Tween system: basic config properties ────────────────────────── + TestCase( + id="T01", + name="Tween config: yoyo/hold/repeatDelay properties", + query="What properties can I set in a TweenBuilderConfig to make a tween yoyo with a hold and repeat delay?", + version="v4.1.0", + topic="tweens", + expected_files=["tweens/builders/TweenBuilder.js", "tweens/typedefs"], + expected_content=["yoyo", "hold", "repeatDelay"], + required_content=["yoyo", "repeatDelay"], + ), + + # ── 2. Tween system: onComplete / onUpdate callbacks ────────────────── + TestCase( + id="T02", + name="Tween callbacks: onComplete and onUpdate signatures", + query="How do I use onComplete and onUpdate callbacks in a Phaser tween? What arguments do they receive?", + version="v4.1.0", + topic="tweens", + expected_files=["tweens/"], + expected_content=["onComplete", "onUpdate", "onStart"], + required_content=["onComplete"], + ), + + # ── 3. Arcade physics: setCollideWorldBounds signature ──────────────── + TestCase( + id="T03", + name="Arcade physics: setCollideWorldBounds signature", + query="What are the parameters of setCollideWorldBounds in Phaser Arcade physics? Can I pass bounceX and bounceY to set bounce on world edges?", + version="v4.1.0", + topic="physics", + expected_files=["physics/arcade/Body.js"], + expected_content=["setCollideWorldBounds", "bounceX", "bounceY", "onWorldBounds"], + required_content=["setCollideWorldBounds", "bounceX"], + ), + + # ── 4. Arcade physics: addCollider vs addOverlap ────────────────────── + TestCase( + id="T04", + name="Arcade physics: addCollider vs addOverlap difference", + query="What is the difference between addCollider and addOverlap in Phaser's Arcade physics World? How do I add a callback?", + version="v4.1.0", + topic="physics", + expected_files=["physics/arcade/World.js"], + expected_content=["addCollider", "addOverlap", "collideCallback", "processCallback"], + required_content=["addCollider", "addOverlap"], + ), + + # ── 5. Camera: shake parameters ─────────────────────────────────────── + TestCase( + id="T05", + name="Camera shake: duration, intensity, force, callback", + query="How do I make the camera shake in Phaser? What parameters does camera.shake accept?", + version="v4.1.0", + topic="camera", + expected_files=["cameras/2d/Camera.js"], + expected_content=["shake", "duration", "intensity", "force", "callback"], + required_content=["shake", "intensity"], + ), + + # ── 6. Camera: startFollow with lerp ───────────────────────────────── + TestCase( + id="T06", + name="Camera follow: startFollow lerpX lerpY parameters", + query="How do I make the Phaser camera follow a player with smooth lerp? What are the lerpX and lerpY parameters?", + version="v4.1.0", + topic="camera", + expected_files=["cameras/2d/Camera.js"], + expected_content=["startFollow", "lerpX", "lerpY", "roundPixels"], + required_content=["startFollow", "lerpX"], + ), + + # ── 7. Camera: setDeadzone ──────────────────────────────────────────── + TestCase( + id="T07", + name="Camera deadzone: setDeadzone width/height", + query="How does camera deadzone work in Phaser? How do I create a rectangular deadzone so the camera only moves when the player exits it?", + version="v4.1.0", + topic="camera", + expected_files=["cameras/2d/Camera.js"], + expected_content=["setDeadzone", "deadzone"], + required_content=["setDeadzone"], + ), + + # ── 8. Scene: pass data when starting another scene ─────────────────── + TestCase( + id="T08", + name="Scene management: pass data on scene.start", + query="How do I pass data to another scene when calling scene.start() or scene.launch()? How does the init method receive it?", + version="v4.1.0", + topic="scenes", + expected_files=["scene/"], + expected_content=["init", "data", "start", "launch"], + required_content=["init"], + ), + + # ── 9. Animation system: chaining animations ────────────────────────── + TestCase( + id="T09", + name="Animation chaining: chain() and playAfterRepeat()", + query="How can I chain multiple animations so one plays after another finishes in Phaser? What is the chain() method?", + version="v4.1.0", + topic="animations", + expected_files=["gameobjects/sprite/Sprite.js", "animations/"], + expected_content=["chain", "playAfterRepeat", "playAfterDelay"], + required_content=["chain"], + ), + + # ── 10. Animation system: events ───────────────────────────────────── + TestCase( + id="T10", + name="Animation events: ANIMATION_COMPLETE, ANIMATION_START", + query="What events does the Phaser animation system emit? How do I listen for when an animation completes on a specific sprite?", + version="v4.1.0", + topic="animations", + expected_files=["animations/events/"], + expected_content=["ANIMATION_COMPLETE", "ANIMATION_START", "ANIMATION_STOP"], + required_content=["ANIMATION_COMPLETE"], + ), + + # ── 11. Input: pointer events ───────────────────────────────────────── + TestCase( + id="T11", + name="Input: setInteractive + pointerdown/pointerover events", + query="How do I call setInteractive on a game object and listen for pointerdown and pointerover events in Phaser?", + version="v4.1.0", + topic="input", + expected_files=["input/"], + expected_content=["pointerdown", "pointerover", "pointerout", "setInteractive"], + required_content=["setInteractive", "pointerdown"], + ), + + # ── 12. Input: keyboard cursor keys ────────────────────────────────── + TestCase( + id="T12", + name="Input: createCursorKeys and keyboard key states", + query="How do I read arrow key input in Phaser? How does createCursorKeys() work and how do I check if a key is down?", + version="v4.1.0", + topic="input", + expected_files=["input/keyboard/"], + expected_content=["createCursorKeys", "isDown", "up", "down", "left", "right"], + required_content=["createCursorKeys"], + ), + + # ── 13. Loader: atlas and texture keys ─────────────────────────────── + TestCase( + id="T13", + name="Loader: load.atlas config object and frame keys", + query="How do I load a texture atlas in Phaser? What are the arguments to this.load.atlas() and how do I use frame keys?", + version="v4.1.0", + topic="loader", + expected_files=["loader/filetypes/AtlasJSONFile.js", "loader/"], + expected_content=["atlas", "textureURL", "atlasURL", "frameConfig"], + required_content=["atlas"], + min_score=0.7, + ), + + # ── 14. Tilemaps: setCollisionBetween ──────────────────────────────── + TestCase( + id="T14", + name="Tilemap: setCollisionBetween start/stop parameters", + query="How do I set collision on a range of tile indices in a Phaser tilemap? What does setCollisionBetween do?", + version="v4.1.0", + topic="tilemaps", + expected_files=["tilemaps/Tilemap.js", "tilemaps/"], + expected_content=["setCollisionBetween", "start", "stop", "collides", "recalculateFaces"], + required_content=["setCollisionBetween"], + ), + + # ── 15. Tilemaps: createFromObjects ────────────────────────────────── + TestCase( + id="T15", + name="Tilemap: createFromObjects from Tiled object layer", + query="How do I convert Tiled object layer objects into Phaser game objects? How does createFromObjects work?", + version="v4.1.0", + topic="tilemaps", + expected_files=["tilemaps/Tilemap.js"], + expected_content=["createFromObjects", "objectLayerName"], + required_content=["createFromObjects"], + ), + + # ── 16. RenderTexture: beginDraw / endDraw (v3 API) ────────────────── + TestCase( + id="T16", + name="RenderTexture v3: beginDraw / batchDraw / endDraw pattern", + query="How do I use beginDraw and endDraw on a Phaser RenderTexture for batch drawing? What is the workflow?", + version="v3.85.2", + topic="rendering", + expected_files=["textures/DynamicTexture.js"], + expected_content=["beginDraw", "endDraw", "batchDraw", "batchDrawFrame"], + required_content=["beginDraw", "endDraw"], + ), + + # ── 17. Masking: BitmapMask vs GeometryMask (v3 API) ────────────────── + TestCase( + id="T17", + name="Masking v3: createBitmapMask vs createGeometryMask", + query="What is the difference between a BitmapMask and a GeometryMask in Phaser? How do I create and apply them?", + version="v3.85.2", + topic="rendering", + expected_files=["gameobjects/components/Mask.js", "display/mask/"], + expected_content=["createBitmapMask", "createGeometryMask", "setMask", "BitmapMask", "GeometryMask"], + required_content=["BitmapMask", "GeometryMask"], + ), + + # ── 18. Groups: getFirstDead / getFirstAlive pool pattern ───────────── + TestCase( + id="T18", + name="Group: object pool with getFirstDead / getFirstAlive", + query="How do I implement an object pool in Phaser using a Group? What are getFirstDead and getFirstAlive?", + version="v4.1.0", + topic="gameobjects", + expected_files=["gameobjects/group/Group.js"], + expected_content=["getFirstDead", "getFirstAlive", "createIfNull", "countActive"], + required_content=["getFirstDead", "getFirstAlive"], + ), + + # ── 19. Matter.js: fromVertices custom body shape ───────────────────── + TestCase( + id="T19", + name="Matter.js: custom body shape with fromVertices", + query="How do I create a custom polygon physics body from vertices in Phaser's Matter.js physics?", + version="v4.1.0", + topic="physics", + expected_files=["physics/matter-js/Factory.js", "physics/matter-js/"], + expected_content=["fromVertices", "vertexSets", "options"], + required_content=["fromVertices"], + ), + + # ── 20. Game config: FPS limit / target ─────────────────────────────── + TestCase( + id="T20", + name="Game config: fps.target and fps.limit settings", + query="How do I configure the target frame rate and FPS limit in the Phaser game config? What is the difference between target and limit?", + version="v4.1.0", + topic="core", + expected_files=["core/TimeStep.js", "core/Config.js"], + expected_content=["targetFps", "fpsLimit", "target", "fps"], + required_content=["targetFps"], + ), + + # ── 21. Scale Manager: ScaleModes ──────────────────────────────────── + TestCase( + id="T21", + name="Scale Manager: FIT vs ENVELOP scale modes", + query="What scale modes are available in Phaser's Scale Manager? How does FIT differ from ENVELOP? How do I make a responsive game?", + version="v4.1.0", + topic="scale", + expected_files=["scale/"], + expected_content=["FIT", "ENVELOP", "ScaleManager", "autoCenter"], + required_content=["FIT"], + ), + + # ── 22. Data Manager: set/get/events ────────────────────────────────── + TestCase( + id="T22", + name="Data Manager: set/get and CHANGE_DATA event", + query="How does the Phaser Data Manager work? How do I watch for data changes using events on a game object's data?", + version="v4.1.0", + topic="data", + expected_files=["data/DataManager.js", "data/"], + expected_content=["CHANGE_DATA", "set", "get", "events"], + required_content=["CHANGE_DATA"], + ), + + # ── 23. Depth sort: setDepth and displayList ────────────────────────── + TestCase( + id="T23", + name="Depth sorting: setDepth and display list ordering", + query="How does Phaser handle rendering order (z-order)? How do I use setDepth to control which objects render on top?", + version="v4.1.0", + topic="rendering", + expected_files=["gameobjects/"], + expected_content=["setDepth", "depth", "displayList"], + required_content=["setDepth"], + ), + + # ── 24. Version diff: v3.60 TweenChain (new in 3.60) ───────────────── + TestCase( + id="T24", + name="Version-specific: TweenChain introduced in v3.60", + query="How do I create a sequence of tweens that play one after another using TweenChain in Phaser 3.60+?", + version="v3.60.0", + topic="tweens", + expected_files=["tweens/"], + expected_content=["TweenChain", "chain"], + required_content=["TweenChain"], + ), + + # ── 25. Hard adversarial: camera.ignore() ───────────────────────────── + TestCase( + id="T25", + name="Camera: ignore() to exclude game objects from a camera", + query="How do I make a game object invisible to a specific camera in Phaser while remaining visible to others? What is camera.ignore()?", + version="v4.1.0", + topic="camera", + expected_files=["cameras/2d/"], + expected_content=["ignore", "camera"], + required_content=["ignore"], + ), +] + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def post_json(url: str, payload: dict) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request( + url, data=body, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + + +def get_json(url: str) -> dict | list: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode()) + + +# --------------------------------------------------------------------------- +# Evaluation logic +# --------------------------------------------------------------------------- + +@dataclass +class TestResult: + test: TestCase + hits: list[dict] + elapsed_ms: float + error: Optional[str] = None + + # Computed below + file_rank: Optional[int] = None # 1-based rank of first expected-file match + content_ranks: list[int] = field(default_factory=list) # 1-based ranks where content found + required_found: bool = False + top_score: Optional[float] = None + + def file_at(self, k: int) -> bool: + return self.file_rank is not None and self.file_rank <= k + + def content_at(self, k: int) -> bool: + return any(r <= k for r in self.content_ranks) + + def mrr(self) -> float: + if self.file_rank is None: + return 0.0 + return 1.0 / self.file_rank + + def summary_line(self) -> str: + f1 = "✓" if self.file_at(1) else "·" + f3 = "✓" if self.file_at(3) else "·" + f5 = "✓" if self.file_at(5) else "·" + c5 = "✓" if self.content_at(5) else "·" + req = "✓" if self.required_found else "✗" + rank_str = f"rank={self.file_rank}" if self.file_rank else "NOT FOUND" + score_str = f"score={self.top_score:.3f}" if self.top_score else "" + ms_str = f"{self.elapsed_ms:.0f}ms" + return ( + f"[{self.test.id}] {self.test.name[:52]:<52} " + f"f@1={f1} f@3={f3} f@5={f5} c@5={c5} req={req} " + f"{rank_str:>12} {score_str} {ms_str}" + ) + + +def evaluate(result: TestResult, verbose: bool = False) -> None: + hits = result.hits + if not hits: + return + + result.top_score = hits[0].get("score") if hits else None + + # File rank: position of first hit whose filePath matches any expected_files substring + for i, hit in enumerate(hits): + fp = hit.get("filePath", "") + if any(ef in fp for ef in result.test.expected_files): + result.file_rank = i + 1 + break + + # Content rank: for each expected_content fragment, find the first hit that contains it + combined_content = {i: (hit.get("content") or "") for i, hit in enumerate(hits)} + + for fragment in result.test.expected_content: + for i, content in combined_content.items(): + if fragment.lower() in content.lower(): + result.content_ranks.append(i + 1) + break + + # Required content: ALL required fragments must appear somewhere in top-10 + all_content = " ".join(combined_content.values()).lower() + result.required_found = all( + r.lower() in all_content for r in result.test.required_content + ) + + if verbose: + print(f"\n{'─'*80}") + print(f"[{result.test.id}] {result.test.name}") + print(f" Query: {result.test.query}") + print(f" Expected files: {result.test.expected_files}") + print(f" Expected content: {result.test.expected_content}") + print(f" Top hits:") + for i, hit in enumerate(hits[:5]): + fp = hit.get("filePath", "?") + score = hit.get("score", 0.0) + snip = (hit.get("content") or "")[:100].replace("\n", " ") + marker = " ← FILE MATCH" if any(ef in fp for ef in result.test.expected_files) else "" + print(f" [{i+1}] score={score:.3f} {fp}{marker}") + print(f" {snip}") + + +# --------------------------------------------------------------------------- +# Main runner +# --------------------------------------------------------------------------- + +def run(base_url: str, verbose: bool) -> None: + base_url = base_url.rstrip("/") + search_url = f"{base_url}/api/search" + versions_url = f"{base_url}/api/repos/{REPO_ID}/versions" + + print(f"TrueRef Phaser RAG Evaluation Suite") + print(f"Server : {base_url}") + print(f"Tests : {len(TESTS)}") + print() + + # Resolve version IDs from server (in case they differ) + try: + all_versions = get_json(versions_url) + live_map = {v["tag"]: v["id"] for v in all_versions if v.get("status") == "INDEXED"} + for tag in list(VERSIONS.keys()): + if tag in live_map: + VERSIONS[tag] = live_map[tag] + except Exception as e: + print(f"WARN: could not refresh version IDs: {e}") + + results: list[TestResult] = [] + + for tc in TESTS: + version_id = VERSIONS.get(tc.version) + if not version_id: + print(f"SKIP [{tc.id}]: version {tc.version} not available") + continue + + payload = { + "text": tc.query, + "scope": [{"repoId": REPO_ID, "versionId": version_id}], + "maxHits": tc.max_hits, + "tokensBudget": tc.tokens_budget, + } + if tc.topic: + payload["topic"] = tc.topic + + t0 = time.time() + try: + resp = post_json(search_url, payload) + elapsed = (time.time() - t0) * 1000 + hits = resp.get("hits", []) + tr = TestResult(test=tc, hits=hits, elapsed_ms=elapsed) + evaluate(tr, verbose=verbose) + except Exception as e: + elapsed = (time.time() - t0) * 1000 + tr = TestResult(test=tc, hits=[], elapsed_ms=elapsed, error=str(e)) + print(f"ERROR [{tc.id}]: {e}") + + results.append(tr) + + # ── Summary table ───────────────────────────────────────────────────── + print() + print("=" * 110) + print(f"{'TEST ID + NAME':<56} {'f@1':>4} {'f@3':>4} {'f@5':>4} {'c@5':>4} {'req':>4} {'file rank':>12} {'score':>10} {'ms':>6}") + print("=" * 110) + + for tr in results: + if tr.error: + print(f"[{tr.test.id}] {'ERROR: ' + tr.test.name[:45]:<52} ERROR: {tr.error[:40]}") + else: + print(tr.summary_line()) + + # ── Aggregate metrics ───────────────────────────────────────────────── + valid = [tr for tr in results if not tr.error] + n = len(valid) + if n == 0: + print("\nNo valid results.") + return + + mrr = sum(tr.mrr() for tr in valid) / n + p_at_1 = sum(1 for tr in valid if tr.file_at(1)) / n + p_at_3 = sum(1 for tr in valid if tr.file_at(3)) / n + p_at_5 = sum(1 for tr in valid if tr.file_at(5)) / n + content_at5 = sum(1 for tr in valid if tr.content_at(5)) / n + req_recall = sum(1 for tr in valid if tr.required_found) / n + avg_ms = sum(tr.elapsed_ms for tr in valid) / n + + print("=" * 110) + print() + print("Aggregate metrics:") + print(f" MRR (file) : {mrr:.4f} ({mrr*100:.1f}%)") + print(f" Precision@1 (file) : {p_at_1:.4f} ({p_at_1*100:.1f}%)") + print(f" Precision@3 (file) : {p_at_3:.4f} ({p_at_3*100:.1f}%)") + print(f" Precision@5 (file) : {p_at_5:.4f} ({p_at_5*100:.1f}%)") + print(f" Content recall@5 : {content_at5:.4f} ({content_at5*100:.1f}%)") + print(f" Required recall : {req_recall:.4f} ({req_recall*100:.1f}%) ← hardest: ALL required fragments in top-10") + print(f" Avg query latency : {avg_ms:.0f} ms") + print() + + # ── Failure analysis ────────────────────────────────────────────────── + failures = [tr for tr in valid if not tr.file_at(5) or not tr.required_found] + if failures: + print(f"Improvement targets ({len(failures)} tests below par):") + for tr in failures: + issues = [] + if not tr.file_at(5): + issues.append(f"file not in top-5 (rank={tr.file_rank})") + if not tr.required_found: + missing = [r for r in tr.test.required_content + if r.lower() not in " ".join(h.get("content","") for h in tr.hits).lower()] + issues.append(f"required content missing: {missing}") + print(f" [{tr.test.id}] {tr.test.name}: {'; '.join(issues)}") + else: + print("All tests passed file@5 and required-content checks.") + + # Exit code: 0 if MRR ≥ 0.5 AND required recall ≥ 0.8, else 1 + if mrr >= 0.5 and req_recall >= 0.8: + print("\nResult: PASS") + sys.exit(0) + else: + print(f"\nResult: FAIL (MRR={mrr:.3f} threshold=0.5, req_recall={req_recall:.3f} threshold=0.8)") + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Phaser RAG quality evaluation") + parser.add_argument("--base-url", default="http://localhost:18080", + help="TrueRef server base URL") + parser.add_argument("--verbose", action="store_true", + help="Print per-test hit details") + args = parser.parse_args() + run(args.base_url, args.verbose) diff --git a/trueref b/trueref new file mode 100755 index 0000000..3cf614b --- /dev/null +++ b/trueref @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# trueref launcher (workspace root) +# +# Wraps the fat JAR with: +# - --enable-native-access=ALL-UNNAMED (silences FFM Linker warning from DJL tokenizers) +# - --add-modules=jdk.incubator.vector (enables Lucene 10 SIMD codepath) +# - cuDNN 9 (cu12 build) on LD_LIBRARY_PATH so ONNX Runtime CUDA EP loads +# - CUDA_VISIBLE_DEVICES isolation so ORT BFC arena doesn't trip over the second card +# - per-session GPU memory cap so embedder + reranker fit on one card +# +# Defaults are tuned for this machine (LMDE 7, CUDA 12.4, RTX 2080 SUPER + RTX 3060). +# Override anything via env vars or by appending Spring properties to the command line. +# +# Usage: +# ./trueref # run with defaults (port 18080) +# ./trueref --server.port=8080 # forward Spring properties +# TRUEREF_GPU=0 ./trueref # use the 2080 SUPER instead# TRUEREF_GPU=cpu ./trueref # disable CUDA, run on CPU +# TRUEREF_HOME=/data/trueref ./trueref # custom data dir +# +# Env vars: +# TRUEREF_GPU GPU index (matches `nvidia-smi -L`) or "cpu". Default: 1 +# TRUEREF_HOME Data directory. Default: ./data +# TRUEREF_PORT HTTP port. Default: 18080 +# TRUEREF_MEM_LIMIT Per-session GPU mem cap in bytes. Default: 0 (unbounded). +# With session-count=1 there is no multi-session contention, so the BFC +# arena can grow freely — capping it risks running out of budget during +# model-weight loading (~1.5-2 GB) before inference even starts. +# Set to e.g. 8589934592 (8 GiB) only if you run multiple pools on one card. +# TRUEREF_CUDNN_LIB Directory containing libcudnn.so.9. Default: ./runtime/cudnn/nvidia/cudnn/lib +# TRUEREF_JAR Path to the fat JAR. Default: ./trueref-bootstrap/target/trueref.jar +# JAVA java binary. Default: $JAVA_HOME/bin/java or `java` on PATH +# JAVA_OPTS Extra JVM flags (e.g. -Xmx16g) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +JAR="${TRUEREF_JAR:-$ROOT/trueref-bootstrap/target/trueref.jar}" +GPU="${TRUEREF_GPU:-1}" +HOME_DIR="${TRUEREF_HOME:-$ROOT/data}" +PORT="${TRUEREF_PORT:-18080}" +MEM_LIMIT="${TRUEREF_MEM_LIMIT:-0}" +CUDNN_LIB="${TRUEREF_CUDNN_LIB:-$ROOT/runtime/cudnn/nvidia/cudnn/lib}" + +if [[ ! -f "$JAR" ]]; then + echo "trueref: jar not found at $JAR" >&2 + echo "trueref: build it first with: mvn -DskipTests -pl trueref-bootstrap -am package" >&2 + exit 1 +fi + +# Resolve java +if [[ -n "${JAVA:-}" ]]; then + : +elif [[ -n "${JAVA_HOME:-}" && -x "${JAVA_HOME}/bin/java" ]]; then + JAVA="${JAVA_HOME}/bin/java" +else + JAVA="$(command -v java || true)" +fi +if [[ -z "${JAVA:-}" || ! -x "${JAVA}" ]]; then + echo "trueref: java not found; install JDK 21+ or set JAVA_HOME" >&2 + exit 1 +fi + +mkdir -p "$HOME_DIR" + +SPRING_ARGS=( + "--server.port=$PORT" + "--trueref.home=$HOME_DIR" +) + +# CUDA setup. "cpu" disables CUDA entirely; otherwise pass the physical GPU index +# directly to ORT. ORT's CUDA EP uses the physical device index regardless of +# CUDA_VISIBLE_DEVICES remapping — so we pass the physical index and explicitly +# unset CUDA_VISIBLE_DEVICES to avoid the two-layer renumbering problem where +# CUDA runtime remaps N→0 but ORT still expects the physical N. +if [[ "$GPU" == "cpu" || "$GPU" == "CPU" ]]; then + echo "trueref: GPU disabled (TRUEREF_GPU=cpu) — embedder/reranker will run on CPU" + SPRING_ARGS+=( + "--trueref.embedding.onnx-providers=cpu" + ) +else + if [[ -d "$CUDNN_LIB" ]]; then + export LD_LIBRARY_PATH="${CUDNN_LIB}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + else + echo "trueref: TRUEREF_CUDNN_LIB=$CUDNN_LIB not found — CUDA EP will fall back to CPU" >&2 + echo "trueref: download cu12 cuDNN with:" >&2 + echo " mkdir -p runtime/cudnn && cd runtime/cudnn && \\" >&2 + echo " pip download --no-deps --only-binary=:all: --python-version 3.12 \\" >&2 + echo " --platform manylinux2014_x86_64 'nvidia-cudnn-cu12<10' -d . && \\" >&2 + echo " unzip -q -o nvidia_cudnn_cu12-*.whl 'nvidia/cudnn/lib/*' && rm *.whl" >&2 + fi + # CUDA runtime respects CUDA_VISIBLE_DEVICES for all allocations (cudaMalloc, BFC arena, + # etc.). By restricting CUDA's view to exactly the target GPU, we prevent the runtime from + # creating a default context on device 0 before ORT's cudaSetDevice() takes effect. + # We always pass gpu-device-id=0 to ORT because CUDA_VISIBLE_DEVICES makes the target + # card the ONLY visible device (index 0 in the runtime's view). + # + # CUDA_DEVICE_ORDER=PCI_BUS_ID ensures CUDA runtime numbering matches nvidia-smi numbering. + # Without it, the default FASTEST_FIRST ordering can rank GPUs differently from nvidia-smi, + # so CUDA_VISIBLE_DEVICES=N would expose a different physical card than nvidia-smi GPU N. + export CUDA_DEVICE_ORDER="PCI_BUS_ID" + export CUDA_VISIBLE_DEVICES="$GPU" + SPRING_ARGS+=( + "--trueref.embedding.gpu-device-id=0" + "--trueref.embedding.gpu-mem-limit-bytes=$MEM_LIMIT" + ) +fi + +exec "$JAVA" \ + --enable-native-access=ALL-UNNAMED \ + --add-modules=jdk.incubator.vector \ + ${JAVA_OPTS:-} \ + -jar "$JAR" \ + "${SPRING_ARGS[@]}" \ + "$@" diff --git a/trueref-adapters/pom.xml b/trueref-adapters/pom.xml new file mode 100644 index 0000000..c895095 --- /dev/null +++ b/trueref-adapters/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + com.trueref + trueref-parent + 0.1.0-SNAPSHOT + + + trueref-adapters + trueref-adapters + All driving (REST, MCP) and driven (H2, Lucene, ONNX, JGit, tree-sitter, disk cache) adapters. + + + + com.trueref + trueref-domain + + + com.trueref + trueref-application + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + + com.h2database + h2 + ${h2.version} + + + org.flywaydb + flyway-core + ${flyway.version} + + + + + org.apache.lucene + lucene-core + + + org.apache.lucene + lucene-analysis-common + + + org.apache.lucene + lucene-queryparser + + + + + com.microsoft.onnxruntime + onnxruntime_gpu + + + ai.djl.huggingface + tokenizers + + + + + org.eclipse.jgit + org.eclipse.jgit + + + org.eclipse.jgit + org.eclipse.jgit.ssh.apache + + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpConfig.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpConfig.java new file mode 100644 index 0000000..9bb9bbe --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpConfig.java @@ -0,0 +1,24 @@ +package com.trueref.adapter.in.mcp; + +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Registers the trueref MCP tool callbacks with Spring AI's MCP WebMVC auto-configuration. The + * {@link MethodToolCallbackProvider} scans {@link TrueRefMcpTools} for methods annotated with + * {@link org.springframework.ai.tool.annotation.Tool} and publishes them on the MCP endpoint + * configured in {@code application.yml} (POST {@code /mcp} via + * {@code spring.ai.mcp.server.sse-message-endpoint}). + */ +@Configuration +@EnableConfigurationProperties(McpProperties.class) +public class McpConfig { + + @Bean + public ToolCallbackProvider trueRefMcpToolCallbacks(TrueRefMcpTools tools) { + return MethodToolCallbackProvider.builder().toolObjects(tools).build(); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpProperties.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpProperties.java new file mode 100644 index 0000000..7ba5603 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/McpProperties.java @@ -0,0 +1,22 @@ +package com.trueref.adapter.in.mcp; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Token-budget defaults for {@code get-library-docs}. Matches Context7 semantics: clients may + * request an explicit token budget per call; unspecified calls use {@link #tokensDefault}. All + * requests are clamped to {@code [tokensMin, tokensMax]}. + */ +@ConfigurationProperties(prefix = "trueref.mcp") +public record McpProperties(int tokensDefault, int tokensMin, int tokensMax) { + + public McpProperties { + if (tokensDefault <= 0) tokensDefault = 5000; + if (tokensMin <= 0) tokensMin = 500; + if (tokensMax <= 0) tokensMax = 50_000; + } + + public int clamp(int requested) { + return Math.max(tokensMin, Math.min(tokensMax, requested)); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/TrueRefMcpTools.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/TrueRefMcpTools.java new file mode 100644 index 0000000..af5a9cc --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/TrueRefMcpTools.java @@ -0,0 +1,297 @@ +package com.trueref.adapter.in.mcp; + +import com.trueref.application.resolve.LibraryResolver; +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.SearchHit; +import com.trueref.domain.model.SearchScope; +import com.trueref.domain.model.Version; +import com.trueref.domain.model.VersionStatus; +import com.trueref.domain.port.in.IndexVersion; +import com.trueref.domain.port.in.QueryCatalog; +import com.trueref.domain.port.in.ResolveLibraryId; +import com.trueref.domain.port.in.SearchLibraryDocs; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Service; + +/** + * Context7-compatible MCP tool handlers. The two tool names, parameter names, and response + * shapes are intentionally 1:1 with upstream Context7 so that any MCP client written against + * Context7 works against trueref unchanged. + * + *

Ranking and version→tag mapping are delegated to the application layer + * ({@link ResolveLibraryId}, {@link LibraryResolver}); hybrid search goes through + * {@link SearchLibraryDocs}; on-demand indexing is enqueued via {@link IndexVersion}. + */ +@Service +public class TrueRefMcpTools { + + private static final Logger log = LoggerFactory.getLogger(TrueRefMcpTools.class); + private static final int DEFAULT_MAX_HITS = 50; + /** Matches Context7's banner retry hint (see ARCHITECTURE §7 on-demand indexing flow). */ + private static final int INDEXING_RETRY_AFTER_SEC = 30; + + private final ResolveLibraryId resolver; + private final LibraryResolver libraryResolver; + private final QueryCatalog catalog; + private final SearchLibraryDocs search; + private final IndexVersion indexer; + private final McpProperties props; + + public TrueRefMcpTools( + ResolveLibraryId resolver, + LibraryResolver libraryResolver, + QueryCatalog catalog, + SearchLibraryDocs search, + IndexVersion indexer, + McpProperties props) { + this.resolver = resolver; + this.libraryResolver = libraryResolver; + this.catalog = catalog; + this.search = search; + this.indexer = indexer; + this.props = props; + } + + @Tool( + name = "resolve-library-id", + description = + "Resolves a package/product name to a trueref-compatible library ID and " + + "returns matching libraries. Context7-compatible. Each result " + + "includes: Title, Context7-compatible library ID (format " + + "/owner/repo[/version]), Description, Code Snippets, Versions, " + + "and a relevance Score.") + public String resolveLibraryId( + @ToolParam( + description = + "Library name to search for and retrieve a Context7-" + + "compatible library ID.") + String libraryName, + @ToolParam( + required = false, + description = + "Optional natural-language query used to rank matching " + + "libraries by relevance.") + @Nullable String query) { + ResolveLibraryId.Result result = + resolver.resolve(new ResolveLibraryId.Query(libraryName, query, null)); + if (result.matches().isEmpty()) { + return "No matching libraries found for: " + libraryName; + } + StringBuilder sb = new StringBuilder(); + for (ResolveLibraryId.Match m : result.matches()) { + appendMatchBlock(sb, m); + } + return sb.toString(); + } + + @Tool( + name = "get-library-docs", + description = + "Fetches up-to-date documentation for a library. You MUST call " + + "'resolve-library-id' first to obtain the exact library ID " + + "required to use this tool, UNLESS the user explicitly provides " + + "a library ID in the format /org/project or /org/project/version.") + public String getLibraryDocs( + @ToolParam( + description = + "Exact trueref-compatible library ID (format: " + + "/org/project or /org/project/version) " + + "retrieved from 'resolve-library-id'.") + String libraryId, + @ToolParam( + required = false, + description = + "Topic to focus the documentation on (e.g. " + + "'routing', 'hooks', 'authentication').") + @Nullable String topic, + @ToolParam( + required = false, + description = + "Max number of tokens to return. Clamped to " + + "[500, 50000]; defaults to 5000.") + @Nullable Integer tokens) { + ParsedId parsed = parseLibraryId(libraryId); + if (parsed == null) { + return "Invalid libraryId: " + libraryId + + ". Expected format: /org/project or /org/project/version."; + } + + Optional repoOpt = catalog.listRepositories().stream() + .filter(r -> r.name().equalsIgnoreCase(parsed.repoName())) + .findFirst(); + if (repoOpt.isEmpty()) { + return "No matching library found for ID: " + libraryId; + } + Repository repo = repoOpt.get(); + List versions = catalog.listVersions(repo.id()); + + SelectedVersion selected = selectVersion(repo, versions, parsed.version()); + if (selected.searchTarget() == null) { + return "No indexed version available for /" + repo.name() + + ". Indexing has been enqueued; retry in ~" + + INDEXING_RETRY_AFTER_SEC + " seconds."; + } + + int budget = props.clamp(tokens == null ? props.tokensDefault() : tokens); + String text = (topic == null || topic.isBlank()) ? repo.name() : topic; + SearchLibraryDocs.Query q = new SearchLibraryDocs.Query( + text, + topic, + new SearchScope(List.of(new SearchScope.RepoVersionRef(repo.id(), selected.searchTarget().id()))), + budget, + DEFAULT_MAX_HITS); + SearchLibraryDocs.Result res; + try { + res = search.search(q); + } catch (Exception e) { + log.warn("MCP search failed for {}: {}", libraryId, e.toString()); + return "Search failed for " + libraryId + ": " + e.getMessage(); + } + + return formatDocs(res.hits(), selected.banner()); + } + + // --- helpers ----------------------------------------------------------- + + private void appendMatchBlock(StringBuilder sb, ResolveLibraryId.Match m) { + sb.append("----------\n"); + sb.append("- Title: ").append(m.name()).append('\n'); + sb.append("- Context7-compatible library ID: ").append(m.libraryId()).append('\n'); + if (m.description() != null && !m.description().isBlank()) { + sb.append("- Description: ").append(m.description()).append('\n'); + } + sb.append("- Code Snippets: ").append(m.snippetCount()).append('\n'); + if (!m.availableVersions().isEmpty()) { + sb.append("- Versions: "); + for (int i = 0; i < m.availableVersions().size(); i++) { + if (i > 0) sb.append(", "); + ResolveLibraryId.VersionRef v = m.availableVersions().get(i); + sb.append(v.tag()).append(" (").append(v.status()).append(')'); + } + sb.append('\n'); + } + sb.append("- Score: ").append(String.format("%.2f", m.score())).append('\n'); + } + + private SelectedVersion selectVersion( + Repository repo, List versions, @Nullable String requestedVersion) { + if (requestedVersion == null || requestedVersion.isBlank()) { + // No version in libraryId: prefer most-recent INDEXED; else nearest DISCOVERED + + // enqueue indexing + banner. + Optional latestIndexed = versions.stream() + .filter(v -> v.status() == VersionStatus.INDEXED) + .max(Comparator.comparing(Version::tag)); + if (latestIndexed.isPresent()) { + return new SelectedVersion(latestIndexed.get(), null); + } + Optional latestDiscovered = versions.stream() + .filter(v -> v.status() == VersionStatus.DISCOVERED) + .max(Comparator.comparing(Version::tag)); + if (latestDiscovered.isPresent()) { + Version v = latestDiscovered.get(); + enqueueSafely(repo, v); + return new SelectedVersion(null, indexingBanner(v.tag(), v.tag())); + } + return new SelectedVersion(null, null); + } + + // Explicit version requested: use application-layer mapper. + Optional mapped = libraryResolver.mapVersion(repo, versions, requestedVersion); + if (mapped.isEmpty()) { + // Fall back to nearest INDEXED; if any, show banner for the requested version. + Optional nearest = versions.stream() + .filter(v -> v.status() == VersionStatus.INDEXED) + .max(Comparator.comparing(Version::tag)); + return new SelectedVersion( + nearest.orElse(null), nearest.map(v -> indexingBanner(requestedVersion, v.tag())).orElse(null)); + } + Version target = mapped.get(); + if (target.status() == VersionStatus.INDEXED) { + return new SelectedVersion(target, null); + } + enqueueSafely(repo, target); + Optional nearestIndexed = versions.stream() + .filter(v -> v.status() == VersionStatus.INDEXED) + .max(Comparator.comparing(Version::tag)); + return new SelectedVersion( + nearestIndexed.orElse(null), + indexingBanner(target.tag(), nearestIndexed.map(Version::tag).orElse("none"))); + } + + private void enqueueSafely(Repository repo, Version v) { + if (v.status() == VersionStatus.INDEXING) return; + try { + indexer.enqueue(repo.id(), v.id(), false); + } catch (Exception e) { + log.warn("MCP on-demand indexing enqueue failed for {}@{}: {}", repo.name(), v.tag(), e.toString()); + } + } + + private String indexingBanner(String requestedTag, String fallbackTag) { + return "[indexing] version " + requestedTag + + " is being indexed now; showing nearest indexed version " + fallbackTag + + " (retryAfterSec=" + INDEXING_RETRY_AFTER_SEC + ")"; + } + + private String formatDocs(List hits, @Nullable String banner) { + StringBuilder sb = new StringBuilder(); + if (banner != null) { + sb.append(banner).append('\n').append('\n'); + } + sb.append("================\n"); + sb.append("CODE SNIPPETS\n"); + sb.append("================\n"); + if (hits.isEmpty()) { + sb.append("(no matching snippets)\n"); + return sb.toString(); + } + for (SearchHit h : hits) { + sb.append("TITLE: ") + .append(h.filePath()) + .append(':') + .append(h.startLine()) + .append('-') + .append(h.endLine()) + .append(" (") + .append(h.language()) + .append(")\n"); + sb.append("SOURCE: ") + .append(h.repoName()) + .append('@') + .append(h.tag()) + .append(" — ") + .append(h.filePath()) + .append("\n\n"); + sb.append("```").append(h.language()).append('\n'); + sb.append(h.content()); + if (!h.content().endsWith("\n")) sb.append('\n'); + sb.append("```\n"); + sb.append("----------------------------------------\n"); + } + return sb.toString(); + } + + static @Nullable ParsedId parseLibraryId(String raw) { + if (raw == null || raw.isBlank()) return null; + String s = raw.startsWith("/") ? raw.substring(1) : raw; + String[] parts = s.split("/"); + if (parts.length == 2) { + return new ParsedId(parts[0] + "/" + parts[1], null); + } + if (parts.length == 3) { + return new ParsedId(parts[0] + "/" + parts[1], parts[2]); + } + return null; + } + + record ParsedId(String repoName, @Nullable String version) {} + + private record SelectedVersion(@Nullable Version searchTarget, @Nullable String banner) {} +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/package-info.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/package-info.java new file mode 100644 index 0000000..1dfd957 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/package-info.java @@ -0,0 +1,16 @@ +/** + * Driving adapter: Model Context Protocol (MCP) server exposing the two Context7-compatible + * tools ({@code resolve-library-id}, {@code get-library-docs}) over Spring AI's MCP WebMVC + * transport. The HTTP message endpoint is wired to {@code POST /mcp} via + * {@code spring.ai.mcp.server.sse-message-endpoint}. + * + *

Spring AI 1.0.0 ships the SSE-based WebMVC transport + * ({@code WebMvcSseServerTransportProvider}); the 2025-03-26 "Streamable HTTP" transport is + * not a separate selectable property in this version. Clients that POST JSON-RPC to the + * configured message endpoint receive JSON-RPC responses; the server additionally opens an + * SSE stream on the configured {@code sse-endpoint} for server-initiated notifications. This + * is the closest equivalent Spring AI 1.0.0 provides to Streamable HTTP and is the + * intended/only transport of this adapter. + */ +@org.jspecify.annotations.NullMarked +package com.trueref.adapter.in.mcp; diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ErrorResponse.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ErrorResponse.java new file mode 100644 index 0000000..eb3a201 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ErrorResponse.java @@ -0,0 +1,16 @@ +package com.trueref.adapter.in.rest; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +/** Uniform error envelope returned by {@link GlobalExceptionHandler}. */ +@Schema(description = "Error response envelope.") +public record ErrorResponse(String code, String message, List fieldErrors) { + + public static ErrorResponse of(String code, String message) { + return new ErrorResponse(code, message, List.of()); + } + + @Schema(description = "A single field-level validation error.") + public record FieldError(String field, String message) {} +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/GlobalExceptionHandler.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/GlobalExceptionHandler.java new file mode 100644 index 0000000..ed2d0ee --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/GlobalExceptionHandler.java @@ -0,0 +1,120 @@ +package com.trueref.adapter.in.rest; + +import com.trueref.domain.error.IngestionFailed; +import com.trueref.domain.error.InvalidSearchRequest; +import com.trueref.domain.error.RepositoryAlreadyRegistered; +import com.trueref.domain.error.RepositoryNotFound; +import com.trueref.domain.error.TagNotFound; +import com.trueref.domain.error.TrueRefException; +import com.trueref.domain.error.VersionNotFound; +import com.trueref.domain.error.VersionNotIndexed; +import jakarta.validation.ConstraintViolationException; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +/** Central translator from domain / validation exceptions to HTTP + {@link ErrorResponse} JSON. */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler({RepositoryNotFound.class, VersionNotFound.class, TagNotFound.class}) + public ResponseEntity handleNotFound(TrueRefException ex) { + return status(HttpStatus.NOT_FOUND, ex); + } + + @ExceptionHandler(RepositoryAlreadyRegistered.class) + public ResponseEntity handleConflict(RepositoryAlreadyRegistered ex) { + return status(HttpStatus.CONFLICT, ex); + } + + @ExceptionHandler(VersionNotIndexed.class) + public ResponseEntity handleNotIndexed(VersionNotIndexed ex) { + return status(HttpStatus.CONFLICT, ex); + } + + @ExceptionHandler(InvalidSearchRequest.class) + public ResponseEntity handleInvalidSearch(InvalidSearchRequest ex) { + return status(HttpStatus.BAD_REQUEST, ex); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + List fieldErrors = ex.getBindingResult().getFieldErrors().stream() + .map(this::toFieldError) + .toList(); + ErrorResponse body = new ErrorResponse("validation_failed", "Request validation failed", fieldErrors); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + List fieldErrors = ex.getConstraintViolations().stream() + .map(v -> new ErrorResponse.FieldError(v.getPropertyPath().toString(), v.getMessage())) + .toList(); + ErrorResponse body = new ErrorResponse("validation_failed", "Request validation failed", fieldErrors); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + ErrorResponse body = new ErrorResponse("invalid_request", safeMessage(ex), List.of()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatus(ResponseStatusException ex) { + HttpStatus resolved = HttpStatus.resolve(ex.getStatusCode().value()); + HttpStatus status = resolved == null ? HttpStatus.INTERNAL_SERVER_ERROR : resolved; + String code = ex.getReason() == null ? status.name().toLowerCase() : ex.getReason(); + return ResponseEntity.status(status).body(ErrorResponse.of(code, code)); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResource(NoResourceFoundException ex) { + // Static resource not found (e.g. browser/.well-known probes). Return 404 without + // logging — these are not application errors and would flood the log at ERROR level. + return ResponseEntity.notFound().build(); + } + + @ExceptionHandler(IngestionFailed.class) + public ResponseEntity handleIngestionFailed(IngestionFailed ex) { + log.error("ingestion failed", ex); + return status(HttpStatus.INTERNAL_SERVER_ERROR, ex); + } + + @ExceptionHandler(TrueRefException.class) + public ResponseEntity handleDomain(TrueRefException ex) { + log.error("unhandled domain error code={}", ex.code(), ex); + return status(HttpStatus.INTERNAL_SERVER_ERROR, ex); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception ex) { + log.error("unexpected error", ex); + ErrorResponse body = new ErrorResponse("internal_error", "An unexpected error occurred", List.of()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } + + private ResponseEntity status(HttpStatus status, TrueRefException ex) { + return ResponseEntity.status(status).body(ErrorResponse.of(ex.code(), safeMessage(ex))); + } + + private ErrorResponse.FieldError toFieldError(FieldError fe) { + String msg = fe.getDefaultMessage() == null ? "invalid" : fe.getDefaultMessage(); + return new ErrorResponse.FieldError(fe.getField(), msg); + } + + private static String safeMessage(Throwable t) { + return t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage(); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/JobController.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/JobController.java new file mode 100644 index 0000000..de6887b --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/JobController.java @@ -0,0 +1,145 @@ +package com.trueref.adapter.in.rest; + +import com.trueref.adapter.in.rest.dto.JobDto; +import com.trueref.adapter.in.rest.dto.JobLogEventDto; +import com.trueref.domain.model.JobId; +import com.trueref.domain.model.JobStatus; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.VersionId; +import com.trueref.domain.port.in.ObserveJobs; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.IOException; +import java.util.List; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** REST + SSE resource: {@code /api/jobs}. */ +@RestController +@RequestMapping("/api/jobs") +@Tag(name = "jobs", description = "Inspect ingestion jobs and stream live progress via SSE.") +public class JobController { + + private static final Logger log = LoggerFactory.getLogger(JobController.class); + + private final ObserveJobs observeJobs; + + public JobController(ObserveJobs observeJobs) { + this.observeJobs = observeJobs; + } + + @Operation(summary = "List jobs, optionally filtered by repo / version / status.") + @GetMapping + public List list( + @RequestParam(value = "repoId", required = false) @Nullable String repoId, + @RequestParam(value = "versionId", required = false) @Nullable String versionId, + @RequestParam(value = "status", required = false) @Nullable JobStatus status, + @RequestParam(value = "limit", defaultValue = "100") int limit) { + RepositoryId repo = repoId == null ? null : RepositoryId.of(repoId); + VersionId ver = versionId == null ? null : VersionId.of(versionId); + return observeJobs.listJobs(repo, ver, status, limit).stream() + .map(JobDto::of) + .toList(); + } + + @Operation(summary = "Get a single job by id.") + @GetMapping("/{id}") + public JobDto detail(@PathVariable("id") String id) { + JobId jobId = JobId.of(id); + return observeJobs + .findJob(jobId) + .map(JobDto::of) + .orElseThrow(() -> + new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "job_not_found")); + } + + @Operation(summary = "Server-Sent Events stream of log events for a single job.") + @GetMapping(value = "/{id}/log", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter logStream(@PathVariable("id") String id) { + JobId jobId = JobId.of(id); + SseEmitter emitter = new SseEmitter(0L); + AutoCloseable subscription = observeJobs.subscribeLogs(jobId, event -> { + try { + emitter.send(SseEmitter.event().name("log").data(JobLogEventDto.of(event))); + } catch (IOException ex) { + emitter.completeWithError(ex); + } + }); + attachCleanup(emitter, subscription, "job-log " + id); + return emitter; + } + + @Operation(summary = "Server-Sent Events stream of status updates for all jobs.") + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream() { + SseEmitter emitter = new SseEmitter(0L); + + // Send an immediate ping so Tomcat flushes the response headers to the client. + // Without this, the response buffer may not be flushed until the first job event + // arrives, keeping the EventSource in CONNECTING state and never firing 'open'. + try { + emitter.send(SseEmitter.event().name("ping").data("")); + } catch (IOException e) { + emitter.completeWithError(e); + return emitter; + } + + AutoCloseable subscription = observeJobs.subscribeJobs(job -> { + try { + emitter.send(SseEmitter.event().name("job").data(JobDto.of(job))); + } catch (IOException ex) { + emitter.completeWithError(ex); + } + }); + + // Keepalive: send a ping every 20 s to keep the connection alive through idle + // periods and detect disconnected clients promptly. + Thread keepalive = Thread.startVirtualThread(() -> { + try { + while (!Thread.currentThread().isInterrupted()) { + Thread.sleep(20_000); + emitter.send(SseEmitter.event().name("ping").data("")); + } + } catch (InterruptedException ignored) { + // normal shutdown + } catch (Exception ignored) { + // emitter already completed or errored; exit + } + }); + + Runnable cleanup = () -> { + keepalive.interrupt(); + try { + subscription.close(); + } catch (Exception ex) { + log.debug("failed to close SSE subscription job-stream: {}", ex.toString()); + } + }; + emitter.onCompletion(cleanup); + emitter.onTimeout(cleanup); + emitter.onError(e -> cleanup.run()); + return emitter; + } + + private static void attachCleanup(SseEmitter emitter, AutoCloseable subscription, String label) { + Runnable cleanup = () -> { + try { + subscription.close(); + } catch (Exception ex) { + log.debug("failed to close SSE subscription {}: {}", label, ex.toString()); + } + }; + emitter.onCompletion(cleanup); + emitter.onTimeout(cleanup); + emitter.onError(e -> cleanup.run()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ObservabilityController.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ObservabilityController.java new file mode 100644 index 0000000..4133cbc --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ObservabilityController.java @@ -0,0 +1,200 @@ +package com.trueref.adapter.in.rest; + +import com.trueref.adapter.in.rest.dto.JobDto; +import com.trueref.adapter.out.embedding.onnx.OnnxProperties; +import com.trueref.domain.model.IngestionJob; +import com.trueref.domain.model.JobStatus; +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.Version; +import com.trueref.domain.model.VersionStatus; +import com.trueref.domain.port.in.ObserveJobs; +import com.trueref.domain.port.in.QueryCatalog; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** REST resource: {@code /api/observability}. */ +@RestController +@RequestMapping("/api/observability") +@Tag(name = "observability", description = "UI-friendly aggregates: metrics + resource usage.") +public class ObservabilityController { + + private static final Logger log = LoggerFactory.getLogger(ObservabilityController.class); + private static final int JOB_SAMPLE_LIMIT = 10_000; + + private final ObserveJobs observeJobs; + private final QueryCatalog queryCatalog; + private final Path trueRefHome; + private final boolean embedderAvailable; + private final boolean rerankerAvailable; + private final int gpuDeviceId; + + public ObservabilityController( + ObserveJobs observeJobs, + QueryCatalog queryCatalog, + OnnxProperties onnxProperties, + @Value("${trueref.home:./data}") String trueRefHome, + @Value("${trueref.embedder.available:true}") boolean embedderAvailable, + @Value("${trueref.reranker.available:true}") boolean rerankerAvailable) { + this.observeJobs = observeJobs; + this.queryCatalog = queryCatalog; + this.trueRefHome = Path.of(trueRefHome); + this.embedderAvailable = embedderAvailable; + this.rerankerAvailable = rerankerAvailable; + this.gpuDeviceId = onnxProperties.gpuDeviceId(); + } + + @Operation(summary = "Aggregated metrics for the dashboard (job counts, totals, availability).") + @GetMapping("/metrics") + public Map metrics() { + Map jobsByStatus = new EnumMap<>(JobStatus.class); + for (JobStatus status : JobStatus.values()) { + jobsByStatus.put(status, 0L); + } + List jobs = observeJobs.listJobs(null, null, null, JOB_SAMPLE_LIMIT); + for (IngestionJob job : jobs) { + jobsByStatus.merge(job.status(), 1L, Long::sum); + } + + long totalChunks = 0L; + long totalVersionsIndexed = 0L; + long totalRepos = 0L; + for (Repository repo : queryCatalog.listRepositories()) { + totalRepos++; + for (Version v : queryCatalog.listVersions(repo.id())) { + totalChunks += v.chunkCount(); + if (v.status() == VersionStatus.INDEXED) { + totalVersionsIndexed++; + } + } + } + + Map result = new HashMap<>(); + result.put("jobsByStatus", toStringKeys(jobsByStatus)); + result.put("jobsSampled", jobs.size()); + result.put("jobsSampleLimit", JOB_SAMPLE_LIMIT); + result.put("totalRepositories", totalRepos); + result.put("totalChunks", totalChunks); + result.put("totalVersionsIndexed", totalVersionsIndexed); + result.put("embedderAvailable", embedderAvailable); + result.put("rerankerAvailable", rerankerAvailable); + return result; + } + + @Operation(summary = "Heap / index-size / cache-size snapshot.") + @GetMapping("/resources") + public Map resources() { + Runtime runtime = Runtime.getRuntime(); + long heapMax = runtime.maxMemory(); + long heapTotal = runtime.totalMemory(); + long heapFree = runtime.freeMemory(); + long heapUsed = heapTotal - heapFree; + + long luceneBytes = directorySizeBytes(trueRefHome.resolve("lucene")); + long cacheBytes = directorySizeBytes(trueRefHome.resolve("embedding-cache")); + + Map heap = new HashMap<>(); + heap.put("usedBytes", heapUsed); + heap.put("totalBytes", heapTotal); + heap.put("maxBytes", heapMax); + + Map result = new HashMap<>(); + result.put("heap", heap); + result.put("luceneIndexBytes", luceneBytes); + result.put("embeddingCacheBytes", cacheBytes); + result.put("trueRefHome", trueRefHome.toAbsolutePath().toString()); + result.put("gpu", queryGpuMemory(gpuDeviceId)); + return result; + } + + /** + * Queries {@code nvidia-smi} for memory stats of the given GPU device index. + * Returns {@code null} when nvidia-smi is absent or the device index is out of range. + */ + private @Nullable Map queryGpuMemory(int deviceId) { + try { + Process proc = new ProcessBuilder( + "nvidia-smi", + "--query-gpu=memory.used,memory.free,memory.total", + "--format=csv,noheader,nounits", + "-i", + String.valueOf(deviceId)) + .redirectErrorStream(true) + .start(); + String line; + try (BufferedReader br = + new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) { + line = br.readLine(); + } + int exit = proc.waitFor(); + if (exit != 0 || line == null || line.isBlank()) { + return null; + } + // Output: "usedMiB, freeMiB, totalMiB" + String[] parts = line.split(","); + if (parts.length < 3) return null; + long usedMiB = Long.parseLong(parts[0].trim()); + long freeMiB = Long.parseLong(parts[1].trim()); + long totalMiB = Long.parseLong(parts[2].trim()); + long mibToBytes = 1024L * 1024L; + Map gpu = new HashMap<>(); + gpu.put("deviceId", deviceId); + gpu.put("usedBytes", usedMiB * mibToBytes); + gpu.put("freeBytes", freeMiB * mibToBytes); + gpu.put("totalBytes", totalMiB * mibToBytes); + return gpu; + } catch (IOException | InterruptedException | NumberFormatException e) { + log.debug("nvidia-smi query failed: {}", e.toString()); + return null; + } + } + + private static Map toStringKeys(Map in) { + Map out = new HashMap<>(); + in.forEach((k, v) -> out.put(k.name(), v)); + return out; + } + + private static long directorySizeBytes(Path dir) { + if (!Files.isDirectory(dir)) { + return 0L; + } + long[] total = {0L}; + try { + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + total[0] += attrs.size(); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + log.warn("failed to walk {}: {}", dir, e.toString()); + } + return total[0]; + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/OpenApiConfig.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/OpenApiConfig.java new file mode 100644 index 0000000..d069880 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/OpenApiConfig.java @@ -0,0 +1,25 @@ +package com.trueref.adapter.in.rest; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Springdoc OpenAPI customization — title, version, summary and default local server. */ +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI trueRefOpenApi() { + Info info = new Info() + .title("trueref API") + .version("0.1.0") + .summary("Self-hosted Context7-compatible ingestion + retrieval HTTP API.") + .description( + "REST endpoints for repository registration, ingestion orchestration, hybrid search and library resolution."); + Server local = new Server().url("http://localhost:8080").description("Local development server"); + return new OpenAPI().info(info).servers(List.of(local)); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/RepositoryController.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/RepositoryController.java new file mode 100644 index 0000000..6b141c5 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/RepositoryController.java @@ -0,0 +1,156 @@ +package com.trueref.adapter.in.rest; + +import com.trueref.adapter.in.rest.dto.RegisterRepositoryRequest; +import com.trueref.adapter.in.rest.dto.RepositoryDto; +import com.trueref.adapter.in.rest.dto.VersionDto; +import com.trueref.domain.error.RepositoryNotFound; +import com.trueref.domain.error.TagNotFound; +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.Version; +import com.trueref.domain.port.in.DiscoverVersions; +import com.trueref.domain.port.in.IndexVersion; +import com.trueref.domain.port.in.QueryCatalog; +import com.trueref.domain.port.in.RegisterRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** REST resource: {@code /api/repos}. */ +@RestController +@RequestMapping("/api/repos") +@Tag(name = "repositories", description = "Register, list, and index git repositories.") +public class RepositoryController { + + private static final Logger log = LoggerFactory.getLogger(RepositoryController.class); + + private final RegisterRepository registerRepository; + private final QueryCatalog queryCatalog; + private final DiscoverVersions discoverVersions; + private final IndexVersion indexVersion; + + public RepositoryController( + RegisterRepository registerRepository, + QueryCatalog queryCatalog, + DiscoverVersions discoverVersions, + IndexVersion indexVersion) { + this.registerRepository = registerRepository; + this.queryCatalog = queryCatalog; + this.discoverVersions = discoverVersions; + this.indexVersion = indexVersion; + } + + @Operation(summary = "List all registered repositories.") + @GetMapping + public List list() { + return queryCatalog.listRepositories().stream().map(RepositoryDto::of).toList(); + } + + @Operation(summary = "Register a new repository (remote URL or local path).") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public RepositoryDto register(@Valid @RequestBody RegisterRepositoryRequest request) { + log.info("registering repository name={}", request.name()); + Repository repo = registerRepository.register(request.toCommand()); + return RepositoryDto.of(repo); + } + + @Operation(summary = "Get details of a single repository.") + @GetMapping("/{id}") + public RepositoryDto detail(@PathVariable("id") String id) { + RepositoryId repoId = parseRepoId(id); + return queryCatalog.findRepository(repoId).map(RepositoryDto::of).orElseThrow(() -> new RepositoryNotFound(id)); + } + + @Operation(summary = "Unregister a repository and soft-delete its versions.") + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void unregister(@PathVariable("id") String id) { + RepositoryId repoId = parseRepoId(id); + log.info("unregistering repository id={}", id); + registerRepository.unregister(repoId); + } + + @Operation(summary = "Force tag discovery (git fetch + enumerate tags). Returns the resulting versions.") + @PostMapping("/{id}/discover") + public List discover(@PathVariable("id") String id) { + RepositoryId repoId = parseRepoId(id); + log.info("discovering versions for repository id={}", id); + return discoverVersions.discover(repoId).stream().map(VersionDto::of).toList(); + } + + @Operation(summary = "List all versions (git tags) known for this repository.") + @GetMapping("/{id}/versions") + public List versions(@PathVariable("id") String id) { + RepositoryId repoId = parseRepoId(id); + // Ensure 404 if the repo does not exist. + queryCatalog.findRepository(repoId).orElseThrow(() -> new RepositoryNotFound(id)); + return queryCatalog.listVersions(repoId).stream().map(VersionDto::of).toList(); + } + + @Operation(summary = "Enqueue indexing of a specific tag. If the tag is unknown, discovery runs first.") + @PostMapping("/{id}/versions/{tag}/index") + public ResponseEntity> index( + @PathVariable("id") String id, + @PathVariable("tag") String tag, + @RequestBody(required = false) IndexBody body) { + boolean force = body != null && Boolean.TRUE.equals(body.force()); + return enqueueIndex(id, tag, force); + } + + @Operation(summary = "Force re-indexing of a specific tag (equivalent to index with force=true).") + @PostMapping("/{id}/versions/{tag}/reindex") + public ResponseEntity> reindex( + @PathVariable("id") String id, @PathVariable("tag") String tag) { + return enqueueIndex(id, tag, true); + } + + private ResponseEntity> enqueueIndex(String id, String tag, boolean force) { + RepositoryId repoId = parseRepoId(id); + Repository repo = queryCatalog.findRepository(repoId).orElseThrow(() -> new RepositoryNotFound(id)); + + Optional existing = findByTag(repoId, tag); + if (existing.isEmpty()) { + log.info("tag {} unknown for repo {}, triggering discovery", tag, repo.name()); + discoverVersions.discover(repoId); + existing = findByTag(repoId, tag); + } + Version version = existing.orElseThrow(() -> new TagNotFound(repo.name(), tag)); + + log.info("enqueueing index job repo={} tag={} force={}", repo.name(), tag, force); + var jobId = indexVersion.enqueue(repoId, version.id(), force); + return ResponseEntity.accepted().body(Map.of("jobId", jobId.toString())); + } + + private Optional findByTag(RepositoryId repoId, String tag) { + return queryCatalog.listVersions(repoId).stream() + .filter(v -> v.tag().equals(tag)) + .findFirst(); + } + + private static RepositoryId parseRepoId(String id) { + try { + return RepositoryId.of(id); + } catch (IllegalArgumentException e) { + throw new RepositoryNotFound(id); + } + } + + /** Body of {@code POST /api/repos/{id}/versions/{tag}/index}. */ + public record IndexBody(Boolean force) {} +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ResolveController.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ResolveController.java new file mode 100644 index 0000000..3685619 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/ResolveController.java @@ -0,0 +1,41 @@ +package com.trueref.adapter.in.rest; + +import com.trueref.adapter.in.rest.dto.ResolveMatchDto; +import com.trueref.adapter.in.rest.dto.ResolveRequest; +import com.trueref.adapter.in.rest.dto.ResolveResponse; +import com.trueref.domain.port.in.ResolveLibraryId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.jspecify.annotations.Nullable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** REST resource: {@code /api/resolve}. */ +@RestController +@RequestMapping("/api/resolve") +@Tag(name = "resolve", description = "Turn a fuzzy library name (and optional version) into concrete (repo, version) handles.") +public class ResolveController { + + private final ResolveLibraryId resolveLibraryId; + + public ResolveController(ResolveLibraryId resolveLibraryId) { + this.resolveLibraryId = resolveLibraryId; + } + + @Operation(summary = "Preview library ID resolution for the given name / version.") + @GetMapping + public ResolveResponse resolve( + @RequestParam("libraryName") String libraryName, + @RequestParam(value = "version", required = false) @Nullable String version, + @RequestParam(value = "query", required = false) @Nullable String query) { + if (libraryName == null || libraryName.isBlank()) { + throw new IllegalArgumentException("libraryName must not be blank"); + } + ResolveRequest req = new ResolveRequest(libraryName, query, version); + ResolveLibraryId.Result result = resolveLibraryId.resolve(req.toQuery()); + return new ResolveResponse( + result.matches().stream().map(ResolveMatchDto::of).toList()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/SearchController.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/SearchController.java new file mode 100644 index 0000000..98e2c36 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/SearchController.java @@ -0,0 +1,47 @@ +package com.trueref.adapter.in.rest; + +import com.trueref.adapter.in.rest.dto.SearchHitDto; +import com.trueref.adapter.in.rest.dto.SearchRequest; +import com.trueref.adapter.in.rest.dto.SearchResponse; +import com.trueref.domain.port.in.SearchLibraryDocs; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** REST resource: {@code /api/search}. */ +@RestController +@RequestMapping("/api/search") +@Tag(name = "search", description = "Hybrid BM25 + dense search with cross-encoder rerank.") +public class SearchController { + + private static final Logger log = LoggerFactory.getLogger(SearchController.class); + + private final SearchLibraryDocs searchLibraryDocs; + + public SearchController(SearchLibraryDocs searchLibraryDocs) { + this.searchLibraryDocs = searchLibraryDocs; + } + + @Operation(summary = "Hybrid search scoped to one or more (repo, version) pairs.") + @PostMapping + public SearchResponse search(@Valid @RequestBody SearchRequest request) { + log.debug( + "search text='{}' topic={} scopes={} tokensBudget={} maxHits={}", + request.text(), + request.topic(), + request.scope().size(), + request.tokensBudget(), + request.maxHits()); + SearchLibraryDocs.Result result = searchLibraryDocs.search(request.toQuery()); + return new SearchResponse( + result.hits().stream().map(SearchHitDto::of).toList(), + result.totalTokensReturned(), + request.topic()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/WebConfig.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/WebConfig.java new file mode 100644 index 0000000..071d9b4 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/WebConfig.java @@ -0,0 +1,54 @@ +package com.trueref.adapter.in.rest; + +import java.io.IOException; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.PathResourceResolver; + +/** + * SPA fallback: any unmatched request that looks like a client-side route (no file extension in + * the final path segment) is served {@code index.html}. API, MCP, springdoc and actuator paths are + * explicitly excluded — Spring routes those first anyway, but we exclude them defensively so the + * resource resolver does not attempt a fallback for them. + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private static final Set EXCLUDED_PREFIXES = + Set.of("api/", "mcp", "swagger-ui/", "v3/api-docs", "actuator/"); + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/") + .resourceChain(true) + .addResolver(new SpaFallbackResolver()); + } + + static final class SpaFallbackResolver extends PathResourceResolver { + @Override + protected @Nullable Resource getResource(String resourcePath, Resource location) throws IOException { + for (String prefix : EXCLUDED_PREFIXES) { + if (resourcePath.equals(prefix) || resourcePath.startsWith(prefix)) { + return null; + } + } + Resource requested = location.createRelative(resourcePath); + if (requested.exists() && requested.isReadable()) { + return requested; + } + // Fallback to index.html only for client-side route-like paths (no extension on last segment). + int lastSlash = resourcePath.lastIndexOf('/'); + String lastSegment = lastSlash < 0 ? resourcePath : resourcePath.substring(lastSlash + 1); + if (!lastSegment.isEmpty() && lastSegment.contains(".")) { + return null; + } + Resource indexHtml = location.createRelative("index.html"); + return indexHtml.exists() && indexHtml.isReadable() ? indexHtml : null; + } + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobDto.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobDto.java new file mode 100644 index 0000000..3a9c882 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobDto.java @@ -0,0 +1,33 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.IngestionJob; +import com.trueref.domain.model.JobStatus; +import com.trueref.domain.model.JobType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import org.jspecify.annotations.Nullable; + +@Schema(description = "An orchestrated ingestion job with its stages.") +public record JobDto( + String id, + String repoId, + @Nullable String versionId, + JobType type, + JobStatus status, + @Nullable Instant startedAt, + @Nullable Instant finishedAt, + List stages) { + + public static JobDto of(IngestionJob j) { + return new JobDto( + j.id().toString(), + j.repoId().toString(), + j.versionId() == null ? null : j.versionId().toString(), + j.type(), + j.status(), + j.startedAt(), + j.finishedAt(), + j.stages().stream().map(JobStageDto::of).toList()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobLogEventDto.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobLogEventDto.java new file mode 100644 index 0000000..bef9a40 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobLogEventDto.java @@ -0,0 +1,16 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.JobLogEvent; +import com.trueref.domain.model.JobStage; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +@Schema(description = "A single log event emitted by an ingestion job.") +public record JobLogEventDto( + String jobId, Instant ts, JobLogEvent.Level level, JobStage.@Nullable StageName stage, String message) { + + public static JobLogEventDto of(JobLogEvent e) { + return new JobLogEventDto(e.jobId().toString(), e.ts(), e.level(), e.stage(), e.message()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobStageDto.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobStageDto.java new file mode 100644 index 0000000..1a60b48 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/JobStageDto.java @@ -0,0 +1,32 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.JobStage; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +@Schema(description = "A single stage within an ingestion job.") +public record JobStageDto( + String jobId, + JobStage.StageName name, + JobStage.StageStatus status, + @Nullable Instant startedAt, + @Nullable Instant finishedAt, + long itemsProcessed, + long itemsTotal, + long bytesProcessed, + @Nullable String errorMessage) { + + public static JobStageDto of(JobStage s) { + return new JobStageDto( + s.jobId().toString(), + s.name(), + s.status(), + s.startedAt(), + s.finishedAt(), + s.itemsProcessed(), + s.itemsTotal(), + s.bytesProcessed(), + s.errorMessage()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/RegisterRepositoryRequest.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/RegisterRepositoryRequest.java new file mode 100644 index 0000000..e7fd587 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/RegisterRepositoryRequest.java @@ -0,0 +1,45 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.TagPattern; +import com.trueref.domain.port.in.RegisterRepository; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.List; +import org.jspecify.annotations.Nullable; + +@Schema(description = "Request payload to register a new repository.") +public record RegisterRepositoryRequest( + @NotBlank @Schema(description = "Human-readable display name, e.g. spring-projects/spring-boot") String name, + @Schema(description = "Remote git URL (mutually exclusive with localPath)") @Nullable String remoteUrl, + @Schema(description = "Absolute local path to an already-cloned repo") @Nullable String localPath, + @Schema(description = "Per-repo ignore globs, ANDed with .gitignore") @Nullable List ignoreGlobs, + @Schema(description = "Max file size in bytes; default 1MiB") @Nullable Long maxFileSizeBytes, + @Schema(description = "ISO-8601 duration (e.g. PT1H); 0 disables polling") @Nullable String pollInterval, + @Schema(description = "Max most-recent tags auto-indexed") @Nullable Integer tagCap, + @Schema(description = "Ordered tag-pattern rules for client version → tag mapping") @Valid @Nullable + List versionMappingRules) { + + public RegisterRepository.Command toCommand() { + Duration poll = parseDuration(pollInterval); + List globs = ignoreGlobs == null ? List.of() : List.copyOf(ignoreGlobs); + List rules = versionMappingRules == null + ? List.of() + : versionMappingRules.stream().map(TagPatternDto::toModel).toList(); + return new RegisterRepository.Command( + name, remoteUrl, localPath, globs, maxFileSizeBytes, poll, tagCap, rules); + } + + private static @Nullable Duration parseDuration(@Nullable String iso) { + if (iso == null || iso.isBlank()) { + return null; + } + try { + return Duration.parse(iso); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid ISO-8601 duration: " + iso, e); + } + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/RepositoryDto.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/RepositoryDto.java new file mode 100644 index 0000000..cc780cc --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/RepositoryDto.java @@ -0,0 +1,39 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.Repository; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import org.jspecify.annotations.Nullable; + +@Schema(description = "A registered repository.") +public record RepositoryDto( + String id, + String name, + @Nullable String remoteUrl, + String localPath, + boolean managedClone, + List ignoreGlobs, + long maxFileSizeBytes, + @Schema(description = "ISO-8601 duration, e.g. PT1H") String pollInterval, + int tagCap, + List versionMappingRules, + Instant createdAt, + Instant updatedAt) { + + public static RepositoryDto of(Repository repo) { + return new RepositoryDto( + repo.id().toString(), + repo.name(), + repo.remoteUrl(), + repo.localPath(), + repo.managedClone(), + List.copyOf(repo.ignoreGlobs()), + repo.maxFileSizeBytes(), + repo.pollInterval().toString(), + repo.tagCap(), + repo.versionMappingRules().stream().map(TagPatternDto::of).toList(), + repo.createdAt(), + repo.updatedAt()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveMatchDto.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveMatchDto.java new file mode 100644 index 0000000..9a2aeb8 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveMatchDto.java @@ -0,0 +1,28 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.port.in.ResolveLibraryId; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import org.jspecify.annotations.Nullable; + +@Schema(description = "A single candidate library matching a resolve request.") +public record ResolveMatchDto( + String repoId, + String libraryId, + String name, + @Nullable String description, + int snippetCount, + List availableVersions, + double score) { + + public static ResolveMatchDto of(ResolveLibraryId.Match m) { + return new ResolveMatchDto( + m.repoId().toString(), + m.libraryId(), + m.name(), + m.description(), + m.snippetCount(), + m.availableVersions().stream().map(ResolveVersionRefDto::of).toList(), + m.score()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveRequest.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveRequest.java new file mode 100644 index 0000000..0c6c81e --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveRequest.java @@ -0,0 +1,17 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.port.in.ResolveLibraryId; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +@Schema(description = "Fuzzy library resolution request.") +public record ResolveRequest( + @NotBlank String libraryName, + @Schema(description = "Optional hint to rerank candidates by relevance") @Nullable String query, + @Nullable String version) { + + public ResolveLibraryId.Query toQuery() { + return new ResolveLibraryId.Query(libraryName, query, version); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveResponse.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveResponse.java new file mode 100644 index 0000000..d1a18c5 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveResponse.java @@ -0,0 +1,7 @@ +package com.trueref.adapter.in.rest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "Ranked library matches for a resolve request.") +public record ResolveResponse(List matches) {} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveVersionRefDto.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveVersionRefDto.java new file mode 100644 index 0000000..6d469a4 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/ResolveVersionRefDto.java @@ -0,0 +1,13 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.VersionStatus; +import com.trueref.domain.port.in.ResolveLibraryId; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "One available version of a resolved library.") +public record ResolveVersionRefDto(String versionId, String tag, VersionStatus status) { + + public static ResolveVersionRefDto of(ResolveLibraryId.VersionRef v) { + return new ResolveVersionRefDto(v.versionId().toString(), v.tag(), v.status()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchHitDto.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchHitDto.java new file mode 100644 index 0000000..e945ba8 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchHitDto.java @@ -0,0 +1,37 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.SearchHit; +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +@Schema(description = "A single ranked snippet returned by search.") +public record SearchHitDto( + String chunkId, + String repoId, + String versionId, + String repoName, + String tag, + String filePath, + int startLine, + int endLine, + String language, + @Nullable String symbol, + String content, + double score) { + + public static SearchHitDto of(SearchHit h) { + return new SearchHitDto( + h.chunkId().toString(), + h.repoId().toString(), + h.versionId().toString(), + h.repoName(), + h.tag(), + h.filePath(), + h.startLine(), + h.endLine(), + h.language(), + h.symbol(), + h.content(), + h.score()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchRequest.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchRequest.java new file mode 100644 index 0000000..dc701d4 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchRequest.java @@ -0,0 +1,41 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.SearchScope; +import com.trueref.domain.model.VersionId; +import com.trueref.domain.port.in.SearchLibraryDocs; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import java.util.List; +import org.jspecify.annotations.Nullable; + +@Schema(description = "Hybrid search request scoped to one or more (repo, version) pairs.") +public record SearchRequest( + @NotBlank String text, + @Nullable String topic, + @NotEmpty @Valid List scope, + @Schema(description = "Token budget; clamped by the service to [500, 50000]") @Positive @Nullable + Integer tokensBudget, + @Positive @Nullable Integer maxHits) { + + public static final int DEFAULT_TOKENS_BUDGET = 5000; + public static final int DEFAULT_MAX_HITS = 20; + + public SearchLibraryDocs.Query toQuery() { + List refs = scope.stream() + .map(r -> new SearchScope.RepoVersionRef(RepositoryId.of(r.repoId()), VersionId.of(r.versionId()))) + .toList(); + return new SearchLibraryDocs.Query( + text, + topic, + new SearchScope(refs), + tokensBudget == null ? DEFAULT_TOKENS_BUDGET : tokensBudget, + maxHits == null ? DEFAULT_MAX_HITS : maxHits); + } + + @Schema(description = "A (repo, version) pair to scope the search on.") + public record ScopeRef(@NotBlank String repoId, @NotBlank String versionId) {} +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchResponse.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchResponse.java new file mode 100644 index 0000000..0a86465 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/SearchResponse.java @@ -0,0 +1,8 @@ +package com.trueref.adapter.in.rest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import org.jspecify.annotations.Nullable; + +@Schema(description = "Response body for search.") +public record SearchResponse(List hits, int totalTokensReturned, @Nullable String topic) {} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/TagPatternDto.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/TagPatternDto.java new file mode 100644 index 0000000..2387d46 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/TagPatternDto.java @@ -0,0 +1,41 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.TagPattern; +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +/** DTO for {@link TagPattern}. The sealed hierarchy is flattened into a {@code type} + optional {@code template}. */ +@Schema(description = "Rule mapping a client-supplied version string to a git tag.") +public record TagPatternDto( + @Schema( + description = "Pattern kind", + allowableValues = {"EXACT", "V_PREFIX", "RELEASE_PREFIX", "SEMVER_FUZZY", "CUSTOM"}) + String type, + @Schema(description = "Required when type=CUSTOM, e.g. release-{semver}") @Nullable String template) { + + public static TagPatternDto of(TagPattern pattern) { + return switch (pattern) { + case TagPattern.Exact e -> new TagPatternDto("EXACT", null); + case TagPattern.VPrefix v -> new TagPatternDto("V_PREFIX", null); + case TagPattern.ReleasePrefix r -> new TagPatternDto("RELEASE_PREFIX", null); + case TagPattern.SemverFuzzy s -> new TagPatternDto("SEMVER_FUZZY", null); + case TagPattern.Custom c -> new TagPatternDto("CUSTOM", c.template()); + }; + } + + public TagPattern toModel() { + return switch (type) { + case "EXACT" -> new TagPattern.Exact(); + case "V_PREFIX" -> new TagPattern.VPrefix(); + case "RELEASE_PREFIX" -> new TagPattern.ReleasePrefix(); + case "SEMVER_FUZZY" -> new TagPattern.SemverFuzzy(); + case "CUSTOM" -> { + if (template == null || template.isBlank()) { + throw new IllegalArgumentException("CUSTOM tag pattern requires a template"); + } + yield new TagPattern.Custom(template); + } + default -> throw new IllegalArgumentException("Unknown tag pattern type: " + type); + }; + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/VersionDto.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/VersionDto.java new file mode 100644 index 0000000..db9471b --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/VersionDto.java @@ -0,0 +1,31 @@ +package com.trueref.adapter.in.rest.dto; + +import com.trueref.domain.model.Version; +import com.trueref.domain.model.VersionStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +@Schema(description = "A specific git tag (or branch) of a repository.") +public record VersionDto( + String id, + String repoId, + String tag, + String commitSha, + VersionStatus status, + @Nullable Instant indexedAt, + int chunkCount, + @Nullable String errorMessage) { + + public static VersionDto of(Version v) { + return new VersionDto( + v.id().toString(), + v.repoId().toString(), + v.tag(), + v.commitSha(), + v.status(), + v.indexedAt(), + v.chunkCount(), + v.errorMessage()); + } +} diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/package-info.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/package-info.java new file mode 100644 index 0000000..79450da --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/dto/package-info.java @@ -0,0 +1,3 @@ +/** Transport-layer DTO records used by {@code com.trueref.adapter.in.rest} controllers. */ +@org.jspecify.annotations.NullMarked +package com.trueref.adapter.in.rest.dto; diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/package-info.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/package-info.java new file mode 100644 index 0000000..efdba58 --- /dev/null +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/rest/package-info.java @@ -0,0 +1,6 @@ +/** + * REST driving adapter: controllers, DTOs, OpenAPI configuration, exception handling and SSE + * streaming for the trueref HTTP API. + */ +@org.jspecify.annotations.NullMarked +package com.trueref.adapter.in.rest; diff --git a/trueref-adapters/src/main/resources/db/migration/V1__init_schema.sql b/trueref-adapters/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..2d00f41 --- /dev/null +++ b/trueref-adapters/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,67 @@ +-- trueref schema V1 +-- All UUIDs stored as CHAR(36) for H2 portability. + +CREATE TABLE repositories ( + id CHAR(36) PRIMARY KEY, + name VARCHAR(512) NOT NULL UNIQUE, + remote_url VARCHAR(2048) NULL, + local_path VARCHAR(2048) NOT NULL, + managed_clone BOOLEAN NOT NULL, + ignore_globs CLOB NOT NULL, -- JSON array of strings + max_file_size_bytes BIGINT NOT NULL, + poll_interval_seconds BIGINT NOT NULL, + tag_cap INT NOT NULL, + version_mapping_rules CLOB NOT NULL, -- JSON array of TagPattern + created_at TIMESTAMP(9) WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP(9) WITH TIME ZONE NOT NULL +); + +CREATE TABLE versions ( + id CHAR(36) PRIMARY KEY, + repo_id CHAR(36) NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + tag VARCHAR(512) NOT NULL, + commit_sha CHAR(40) NOT NULL, + status VARCHAR(32) NOT NULL, + indexed_at TIMESTAMP(9) WITH TIME ZONE NULL, + chunk_count INT NOT NULL DEFAULT 0, + error_message CLOB NULL, + UNIQUE (repo_id, tag) +); +CREATE INDEX idx_versions_repo_status ON versions(repo_id, status); + +CREATE TABLE ingestion_jobs ( + id CHAR(36) PRIMARY KEY, + repo_id CHAR(36) NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + version_id CHAR(36) NULL REFERENCES versions(id) ON DELETE CASCADE, + type VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + started_at TIMESTAMP(9) WITH TIME ZONE NULL, + finished_at TIMESTAMP(9) WITH TIME ZONE NULL, + created_at TIMESTAMP(9) WITH TIME ZONE NOT NULL +); +CREATE INDEX idx_jobs_repo_status ON ingestion_jobs(repo_id, status); +CREATE INDEX idx_jobs_status_created ON ingestion_jobs(status, created_at); + +CREATE TABLE job_stages ( + job_id CHAR(36) NOT NULL REFERENCES ingestion_jobs(id) ON DELETE CASCADE, + name VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + started_at TIMESTAMP(9) WITH TIME ZONE NULL, + finished_at TIMESTAMP(9) WITH TIME ZONE NULL, + items_processed BIGINT NOT NULL DEFAULT 0, + items_total BIGINT NOT NULL DEFAULT 0, + bytes_processed BIGINT NOT NULL DEFAULT 0, + error_message CLOB NULL, + PRIMARY KEY (job_id, name) +); + +-- Persisted log buffer (last N per job kept by application logic; SSE streams from in-memory bus). +CREATE TABLE job_log_events ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + job_id CHAR(36) NOT NULL REFERENCES ingestion_jobs(id) ON DELETE CASCADE, + ts TIMESTAMP(9) WITH TIME ZONE NOT NULL, + level VARCHAR(8) NOT NULL, + stage VARCHAR(32), + message CLOB NOT NULL +); +CREATE INDEX idx_job_log_job_ts ON job_log_events(job_id, ts); diff --git a/trueref-application/pom.xml b/trueref-application/pom.xml new file mode 100644 index 0000000..727892e --- /dev/null +++ b/trueref-application/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + + com.trueref + trueref-parent + 0.1.0-SNAPSHOT + + + trueref-application + trueref-application + Use-case implementations. Depends only on the domain. + + + + com.trueref + trueref-domain + + + + org.slf4j + slf4j-api + + + diff --git a/trueref-application/src/main/java/com/trueref/application/catalog/CatalogService.java b/trueref-application/src/main/java/com/trueref/application/catalog/CatalogService.java new file mode 100644 index 0000000..f7e7585 --- /dev/null +++ b/trueref-application/src/main/java/com/trueref/application/catalog/CatalogService.java @@ -0,0 +1,89 @@ +package com.trueref.application.catalog; + +import com.trueref.domain.error.RepositoryAlreadyRegistered; +import com.trueref.domain.error.RepositoryNotFound; +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.TagPattern; +import com.trueref.domain.model.Version; +import com.trueref.domain.port.in.QueryCatalog; +import com.trueref.domain.port.in.RegisterRepository; +import com.trueref.domain.port.out.RepositoryStore; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Implements {@link RegisterRepository} and {@link QueryCatalog}. */ +public final class CatalogService implements RegisterRepository, QueryCatalog { + + private static final Logger log = LoggerFactory.getLogger(CatalogService.class); + + private static final List DEFAULT_RULES = List.of( + new TagPattern.Exact(), + new TagPattern.VPrefix(), + new TagPattern.ReleasePrefix(), + new TagPattern.SemverFuzzy()); + + private final RepositoryStore store; + private final Path trueRefHome; + + public CatalogService(RepositoryStore store, Path trueRefHome) { + this.store = store; + this.trueRefHome = trueRefHome; + } + + @Override + public Repository register(Command cmd) { + store.findByName(cmd.name()).ifPresent(r -> { + throw new RepositoryAlreadyRegistered(cmd.name()); + }); + boolean managed = cmd.remoteUrl() != null && cmd.localPath() == null; + String localPath = cmd.localPath() != null + ? cmd.localPath() + : trueRefHome.resolve("repos").resolve(cmd.name().replace('/', '_')).toString(); + Instant now = Instant.now(); + Repository repo = new Repository( + RepositoryId.random(), + cmd.name(), + cmd.remoteUrl(), + localPath, + managed, + cmd.ignoreGlobs(), + cmd.maxFileSizeBytes() == null ? 1_048_576L : cmd.maxFileSizeBytes(), + cmd.pollInterval() == null ? Duration.ofHours(1) : cmd.pollInterval(), + cmd.tagCap() == null ? 100 : cmd.tagCap(), + cmd.versionMappingRules().isEmpty() ? DEFAULT_RULES : cmd.versionMappingRules(), + now, + now); + Repository saved = store.save(repo); + log.info("registered repository name={} id={} managed={} localPath={}", + saved.name(), saved.id(), managed, localPath); + return saved; + } + + @Override + public void unregister(RepositoryId id) { + Repository existing = store.findById(id).orElseThrow(() -> new RepositoryNotFound(id.toString())); + store.delete(id); + log.info("unregistered repository name={} id={}", existing.name(), id); + } + + @Override + public List listRepositories() { + return store.findAll(); + } + + @Override + public Optional findRepository(RepositoryId id) { + return store.findById(id); + } + + @Override + public List listVersions(RepositoryId repoId) { + return store.findVersionsByRepo(repoId); + } +} diff --git a/trueref-application/src/main/java/com/trueref/application/ingest/DiscoveryService.java b/trueref-application/src/main/java/com/trueref/application/ingest/DiscoveryService.java new file mode 100644 index 0000000..17b1a91 --- /dev/null +++ b/trueref-application/src/main/java/com/trueref/application/ingest/DiscoveryService.java @@ -0,0 +1,87 @@ +package com.trueref.application.ingest; + +import com.trueref.domain.error.RepositoryNotFound; +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.Version; +import com.trueref.domain.model.VersionId; +import com.trueref.domain.model.VersionStatus; +import com.trueref.domain.port.in.DiscoverVersions; +import com.trueref.domain.port.out.GitClient; +import com.trueref.domain.port.out.RepositoryStore; +import com.trueref.domain.model.RepositoryId; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Fetches tags (git fetch + tag list) and persists new/updated {@link Version}s. */ +public final class DiscoveryService implements DiscoverVersions { + + private static final Logger log = LoggerFactory.getLogger(DiscoveryService.class); + + private final RepositoryStore store; + private final GitClient git; + + public DiscoveryService(RepositoryStore store, GitClient git) { + this.store = store; + this.git = git; + } + + @Override + public List discover(RepositoryId repoId) { + Repository repo = store.findById(repoId).orElseThrow(() -> new RepositoryNotFound(repoId.toString())); + Path path = Path.of(repo.localPath()); + + // clone if managed and not present + if (repo.managedClone() && !Files.exists(path.resolve(".git"))) { + log.info("cloning for discovery: {}", repo.name()); + git.cloneRepo(repo.remoteUrl(), path); + } else { + try { git.fetch(path); } catch (Exception e) { + log.warn("fetch failed for {}: {}", repo.name(), e.toString()); + } + } + + List tags = git.listTags(path); + // apply tag cap: keep top-N by epoch DESC (already sorted) + List capped = tags.stream().limit(Math.max(1, repo.tagCap())).toList(); + + for (GitClient.TagInfo t : capped) { + Optional existing = store.findVersionByTag(repoId, t.name()); + if (existing.isPresent()) { + // refresh commit sha only if changed + if (!existing.get().commitSha().equalsIgnoreCase(t.commitSha())) { + Version updated = new Version( + existing.get().id(), + existing.get().repoId(), + t.name(), + t.commitSha(), + VersionStatus.DISCOVERED, // needs re-index + existing.get().indexedAt(), + existing.get().chunkCount(), + null); + store.saveVersion(updated); + log.info("tag {} changed commit; marked DISCOVERED", t.name()); + } + } else { + Version v = new Version( + VersionId.random(), + repoId, + t.name(), + t.commitSha(), + VersionStatus.DISCOVERED, + null, + 0, + null); + store.saveVersion(v); + log.info("discovered new tag {}", t.name()); + } + } + return store.findVersionsByRepo(repoId).stream() + .sorted(Comparator.comparing(Version::tag)) + .toList(); + } +} diff --git a/trueref-application/src/main/java/com/trueref/application/ingest/IngestionOrchestrator.java b/trueref-application/src/main/java/com/trueref/application/ingest/IngestionOrchestrator.java new file mode 100644 index 0000000..ec3d438 --- /dev/null +++ b/trueref-application/src/main/java/com/trueref/application/ingest/IngestionOrchestrator.java @@ -0,0 +1,604 @@ +package com.trueref.application.ingest; + +import com.trueref.domain.model.Chunk; +import com.trueref.domain.model.ChunkId; +import com.trueref.domain.model.ChunkVersion; +import com.trueref.domain.model.Embedding; +import com.trueref.domain.model.IngestionJob; +import com.trueref.domain.model.JobId; +import com.trueref.domain.model.JobLogEvent; +import com.trueref.domain.model.JobStage; +import com.trueref.domain.model.JobStatus; +import com.trueref.domain.model.JobType; +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.Version; +import com.trueref.domain.model.VersionId; +import com.trueref.domain.model.VersionStatus; +import com.trueref.domain.port.in.IndexVersion; +import com.trueref.domain.port.out.ChunkStore; +import com.trueref.domain.port.out.CodeParser; +import com.trueref.domain.port.out.EmbeddingCache; +import com.trueref.domain.port.out.EmbeddingService; +import com.trueref.domain.port.out.GitClient; +import com.trueref.domain.port.out.JobEventBus; +import com.trueref.domain.port.out.JobStore; +import com.trueref.domain.port.out.RepositoryStore; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +/** + * Orchestrates the full ingestion pipeline for one (repo, version): clone/fetch → checkout → + * discover files → diff-vs-parent → parse → chunk → dedupe by hash → embed (with cache) → index + * into Lucene → commit. + * + *

The pipeline is split into two concurrent stages: + *

    + *
  1. Parse phase (virtual threads, up to {@code maxParseJobs} in parallel): + * FETCH/CLONE → CHECKOUT → DISCOVER_FILES → DIFF_FILES → PARSE. + * I/O-bound; no GPU use; worktree is removed immediately after parse. + *
  2. + *
  3. Embed phase (single dedicated platform thread): + * EMBED → INDEX → COMMIT. GPU-bound; serialises ONNX inference to prevent CUDA + * context races. Runs on a platform thread for a stable OS thread identity. + *
  4. + *
+ * Completed parse batches are handed off via a bounded {@link BlockingQueue}: if the embed + * worker is busy, parse workers block before queuing, naturally capping in-memory pressure. + * One orchestrator instance is shared across all jobs. + */ +public final class IngestionOrchestrator implements IndexVersion { + + private static final Logger log = LoggerFactory.getLogger(IngestionOrchestrator.class); + + // Built-in ignore globs (applied in addition to .gitignore + per-repo globs). + private static final List BUILTIN_IGNORES = List.of( + "**/.git/**", + "**/node_modules/**", + "**/target/**", + "**/build/**", + "**/dist/**", + "**/out/**", + "**/.idea/**", + "**/.vscode/**", + "**/__pycache__/**", + "**/*.png", "**/*.jpg", "**/*.jpeg", "**/*.gif", "**/*.webp", "**/*.ico", + "**/*.pdf", "**/*.zip", "**/*.tar", "**/*.gz", "**/*.jar", "**/*.class", + "**/*.so", "**/*.dll", "**/*.dylib", "**/*.exe", "**/*.bin"); + + private final RepositoryStore repoStore; + private final JobStore jobStore; + private final ChunkStore chunkStore; + private final EmbeddingService embeddings; + private final EmbeddingCache embeddingCache; + private final GitClient git; + private final CodeParser parser; + private final JobEventBus bus; + + private final ExecutorService parseExecutor; + private final Semaphore parseConcurrencyLimit; + private final BlockingQueue embedQueue; + private final Thread embedWorker; + private volatile boolean shuttingDown = false; + private final Map running = new ConcurrentHashMap<>(); + + public IngestionOrchestrator( + RepositoryStore repoStore, + JobStore jobStore, + ChunkStore chunkStore, + EmbeddingService embeddings, + EmbeddingCache embeddingCache, + GitClient git, + CodeParser parser, + JobEventBus bus, + int maxParseJobs, + int embedQueueCapacity) { + this.repoStore = repoStore; + this.jobStore = jobStore; + this.chunkStore = chunkStore; + this.embeddings = embeddings; + this.embeddingCache = embeddingCache; + this.git = git; + this.parser = parser; + this.bus = bus; + this.parseExecutor = Executors.newVirtualThreadPerTaskExecutor(); + // Fair semaphore caps parallel parse jobs (I/O + CPU heavy, no GPU). + this.parseConcurrencyLimit = new Semaphore(Math.max(1, maxParseJobs), true); + // Bounded queue between parse workers and the embed worker. + // Backpressure: parse workers block here when the embed worker is saturated, + // preventing unbounded in-memory accumulation of parsed chunks. + this.embedQueue = new LinkedBlockingQueue<>(Math.max(1, embedQueueCapacity)); + // Single platform thread for GPU inference. Platform (not virtual) gives a + // stable OS thread identity for CUDA — the synchronized(session) in OnnxEmbeddingService + // already pins virtual threads, but a dedicated platform thread removes all doubt. + this.embedWorker = Thread.ofPlatform() + .name("embed-worker") + .daemon(false) + .start(this::drainEmbedQueue); + log.info("IngestionOrchestrator ready: maxParseJobs={} embedQueueCapacity={}", + Math.max(1, maxParseJobs), Math.max(1, embedQueueCapacity)); + } + + @Override + public JobId enqueue(RepositoryId repoId, VersionId versionId, boolean force) { + Repository repo = repoStore.findById(repoId).orElseThrow(); + Version ver = repoStore.findVersion(versionId).orElseThrow(); + if (!force && ver.status() == VersionStatus.INDEXED) { + log.info("version already indexed and not forcing; skipping repo={} tag={}", repo.name(), ver.tag()); + JobId id = JobId.random(); + IngestionJob skipped = new IngestionJob( + id, + repoId, + versionId, + JobType.INDEX_VERSION, + JobStatus.SUCCEEDED, + Instant.now(), + Instant.now(), + List.of()); + jobStore.save(skipped); + bus.publishJob(skipped); + return id; + } + + JobId jobId = JobId.random(); + IngestionJob job = new IngestionJob( + jobId, + repoId, + versionId, + JobType.INDEX_VERSION, + JobStatus.QUEUED, + null, + null, + List.of()); + jobStore.save(job); + bus.publishJob(job); + running.put(jobId, Boolean.TRUE); + parseExecutor.submit(() -> runParsePhase(jobId, repo, ver)); + return jobId; + } + + /** + * Carry struct that transfers parse-phase output to the embed worker. + * The git worktree has already been removed before this batch enters the queue; + * only in-memory chunk data travels across the thread boundary. + */ + private record ParsedBatch( + JobId jobId, Repository repo, Version ver, List pieces) {} + + /** + * Parse phase — runs on a virtual thread, up to {@code maxParseJobs} in parallel. + * Stages: FETCH/CLONE → CHECKOUT → DISCOVER_FILES → DIFF_FILES → PARSE. + * On completion, removes the worktree, releases the parse slot so the next job can + * start immediately, then blocks on {@link #embedQueue} until the embed worker has + * room (natural backpressure). + */ + private void runParsePhase(JobId jobId, Repository repo, Version ver) { + MDC.put("jobId", jobId.toString()); + MDC.put("repo", repo.name()); + MDC.put("tag", ver.tag()); + try { + parseConcurrencyLimit.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("job {} interrupted while waiting for parse slot — failing", jobId); + repoStore.updateVersionStatus(ver.id(), VersionStatus.FAILED, "interrupted"); + transitionJob(jobId, JobStatus.FAILED, null, Instant.now()); + running.remove(jobId); + MDC.clear(); + return; + } + boolean slotReleased = false; + Path worktree = null; + Path repoPath = Path.of(repo.localPath()); + try { + transitionJob(jobId, JobStatus.RUNNING, Instant.now(), null); + repoStore.updateVersionStatus(ver.id(), VersionStatus.INDEXING, null); + + // STAGE: FETCH (or CLONE if managed and absent) + if (repo.managedClone() && !Files.exists(repoPath.resolve(".git"))) { + stage(jobId, JobStage.StageName.CLONE, () -> { + logEvent(jobId, JobLogEvent.Level.INFO, JobStage.StageName.CLONE, + "cloning " + repo.remoteUrl() + " → " + repoPath); + git.cloneRepo(repo.remoteUrl(), repoPath); + return 1L; + }); + } else { + stage(jobId, JobStage.StageName.FETCH, () -> { + git.fetch(repoPath); + return 1L; + }); + } + + // STAGE: CHECKOUT + final Path wt = stageReturning(jobId, JobStage.StageName.CHECKOUT, () -> { + Path w = git.checkoutWorktree(repoPath, ver.tag()); + logEvent(jobId, JobLogEvent.Level.INFO, JobStage.StageName.CHECKOUT, + "checked out at " + w); + return w; + }); + worktree = wt; + + // STAGE: DISCOVER_FILES + List files = stageReturning(jobId, JobStage.StageName.DISCOVER_FILES, () -> + discoverFiles(wt, repo)); + logEvent(jobId, JobLogEvent.Level.INFO, JobStage.StageName.DISCOVER_FILES, + "found " + files.size() + " indexable files"); + + // STAGE: DIFF_FILES (select subset) + String baseRef = pickParentIndexedTag(repo, ver); + final List selectedFiles; + if (baseRef != null) { + Set changedRel = stageReturning(jobId, JobStage.StageName.DIFF_FILES, () -> { + List diff = git.diff(repoPath, baseRef, ver.tag()); + Set s = new HashSet<>(); + for (GitClient.DiffEntry e : diff) { + if (e.change() != GitClient.DiffEntry.ChangeType.DELETED) s.add(e.path()); + } + return s; + }); + selectedFiles = files.stream() + .filter(f -> changedRel.contains(wt.relativize(f).toString().replace('\\', '/'))) + .toList(); + logEvent(jobId, JobLogEvent.Level.INFO, JobStage.StageName.DIFF_FILES, + "diff vs " + baseRef + " selects " + selectedFiles.size() + "/" + files.size()); + } else { + selectedFiles = files; + } + + // STAGE: PARSE + CHUNK + HASH (combined) + List pieces = stageReturning(jobId, JobStage.StageName.PARSE, () -> + parseAll(selectedFiles, wt)); + + // Worktree no longer needed — free disk space before blocking on embed queue. + removeWorktreeQuietly(jobId, repoPath, wt); + worktree = null; + + // Release parse slot before blocking so the next job can start parsing + // while this batch waits for the embed worker (maximises CPU/GPU overlap). + parseConcurrencyLimit.release(); + slotReleased = true; + + // Hand off to embed worker — blocks if the queue is at capacity. + embedQueue.put(new ParsedBatch(jobId, repo, ver, pieces)); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("job {} interrupted during parse — failing", jobId); + repoStore.updateVersionStatus(ver.id(), VersionStatus.FAILED, "interrupted"); + transitionJob(jobId, JobStatus.FAILED, null, Instant.now()); + running.remove(jobId); + } catch (Exception e) { + log.error("parse phase failed for job {}", jobId, e); + logEvent(jobId, JobLogEvent.Level.ERROR, null, "parse phase failed: " + e.getMessage()); + repoStore.updateVersionStatus(ver.id(), VersionStatus.FAILED, e.getMessage()); + transitionJob(jobId, JobStatus.FAILED, null, Instant.now()); + running.remove(jobId); + } finally { + if (worktree != null) removeWorktreeQuietly(jobId, repoPath, worktree); + if (!slotReleased) parseConcurrencyLimit.release(); + MDC.clear(); + } + } + + /** + * Embed worker — runs on a single dedicated platform thread. + * Drains {@link #embedQueue} until {@link #shutdown()} signals stop. + * Stages per batch: EMBED → INDEX → COMMIT → mark version indexed → transition SUCCEEDED. + */ + private void drainEmbedQueue() { + log.info("embed worker started ({})", Thread.currentThread().getName()); + while (!shuttingDown || !embedQueue.isEmpty()) { + ParsedBatch batch; + try { + batch = embedQueue.poll(500, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + if (batch == null) continue; + runEmbedPhase(batch); + } + log.info("embed worker stopped"); + } + + private void runEmbedPhase(ParsedBatch batch) { + MDC.put("jobId", batch.jobId().toString()); + MDC.put("repo", batch.repo().name()); + MDC.put("tag", batch.ver().tag()); + try { + // STAGE: EMBED + List chunks = stageReturning(batch.jobId(), JobStage.StageName.EMBED, () -> + embedAll(batch.jobId(), batch.pieces())); + + // STAGE: INDEX + stage(batch.jobId(), JobStage.StageName.INDEX, () -> { + chunkStore.unlinkVersion(batch.ver().id()); + List links = buildLinks(batch.ver().id(), batch.pieces()); + chunkStore.linkChunks(links); + return (long) links.size(); + }); + + // STAGE: COMMIT + stage(batch.jobId(), JobStage.StageName.COMMIT, () -> { + chunkStore.commit(); + return 1L; + }); + + repoStore.markVersionIndexed(batch.ver().id(), batch.pieces().size()); + transitionJob(batch.jobId(), JobStatus.SUCCEEDED, null, Instant.now()); + logEvent(batch.jobId(), JobLogEvent.Level.INFO, null, + "indexed " + chunks.size() + " chunks across " + batch.pieces().size() + " pieces"); + } catch (Exception e) { + log.error("embed phase failed for job {}", batch.jobId(), e); + logEvent(batch.jobId(), JobLogEvent.Level.ERROR, null, + "embed phase failed: " + e.getMessage()); + repoStore.updateVersionStatus(batch.ver().id(), VersionStatus.FAILED, e.getMessage()); + transitionJob(batch.jobId(), JobStatus.FAILED, null, Instant.now()); + } finally { + running.remove(batch.jobId()); + MDC.clear(); + } + } + + private void removeWorktreeQuietly(JobId jobId, Path repoPath, Path worktree) { + try { + git.removeWorktree(repoPath, worktree); + } catch (Exception e) { + logEvent(jobId, JobLogEvent.Level.WARN, null, "worktree cleanup failed: " + e.getMessage()); + } + } + + /** + * Orderly shutdown: stops the parse executor, signals the embed worker to stop after + * finishing its current batch, then fails any batches still in the queue. + * Called by Spring via {@code @Bean(destroyMethod = "shutdown")} in ApplicationBeans. + */ + void shutdown() { + log.info("IngestionOrchestrator shutting down — stopping embed worker"); + shuttingDown = true; + parseExecutor.shutdownNow(); + embedWorker.interrupt(); + try { + embedWorker.join(10_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + // Fail any batches that parsed OK but never got to embed (restart mid-queue). + ParsedBatch orphan; + while ((orphan = embedQueue.poll()) != null) { + log.warn("failing orphaned embed batch for job {} (shutdown)", orphan.jobId()); + repoStore.updateVersionStatus(orphan.ver().id(), VersionStatus.FAILED, "application shutdown"); + transitionJob(orphan.jobId(), JobStatus.FAILED, null, Instant.now()); + running.remove(orphan.jobId()); + } + } + + /* ------------------------------------------------------------------ */ + /* Stage helpers */ + /* ------------------------------------------------------------------ */ + + private interface StageBody { + long execute() throws Exception; + } + + private interface StageBodyReturning { + T execute() throws Exception; + } + + private void stage(JobId id, JobStage.StageName name, StageBody body) { + stageReturning(id, name, () -> { + long n = body.execute(); + return n; + }); + } + + private T stageReturning(JobId id, JobStage.StageName name, StageBodyReturning body) { + Instant start = Instant.now(); + JobStage running = new JobStage( + id, name, JobStage.StageStatus.RUNNING, start, null, 0, 0, 0, null); + jobStore.upsertStage(running); + publishJob(id); + try { + T out = body.execute(); + long items = (out instanceof Long l) ? l : (out instanceof List l ? l.size() : 1); + JobStage done = new JobStage( + id, name, JobStage.StageStatus.SUCCEEDED, start, Instant.now(), items, items, 0, null); + jobStore.upsertStage(done); + publishJob(id); + return out; + } catch (Exception e) { + JobStage failed = new JobStage( + id, name, JobStage.StageStatus.FAILED, start, Instant.now(), 0, 0, 0, e.getMessage()); + jobStore.upsertStage(failed); + publishJob(id); + throw new RuntimeException(e); + } + } + + private void transitionJob(JobId id, JobStatus s, Instant startedAt, Instant finishedAt) { + jobStore.updateStatus(id, s, startedAt, finishedAt); + publishJob(id); + } + + private void publishJob(JobId id) { + jobStore.findById(id).ifPresent(bus::publishJob); + } + + private void logEvent(JobId id, JobLogEvent.Level level, JobStage.StageName stage, String msg) { + bus.publishLog(new JobLogEvent(id, Instant.now(), level, stage, msg)); + } + + /* ------------------------------------------------------------------ */ + /* Pipeline steps */ + /* ------------------------------------------------------------------ */ + + private List discoverFiles(Path root, Repository repo) throws IOException { + List matchers = new ArrayList<>(); + for (String g : BUILTIN_IGNORES) matchers.add(FileSystems.getDefault().getPathMatcher("glob:" + g)); + for (String g : repo.ignoreGlobs()) matchers.add(FileSystems.getDefault().getPathMatcher("glob:" + g)); + long maxBytes = repo.maxFileSizeBytes(); + List out = new ArrayList<>(); + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + Path rel = root.relativize(file); + for (PathMatcher m : matchers) { + if (m.matches(rel) || m.matches(file.getFileName())) return FileVisitResult.CONTINUE; + } + if (attrs.size() > maxBytes) return FileVisitResult.CONTINUE; + out.add(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (dir.equals(root)) return FileVisitResult.CONTINUE; + Path rel = root.relativize(dir); + for (PathMatcher m : matchers) { + if (m.matches(rel)) return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + }); + return out; + } + + private record ParsedPiece( + String contentHash, + String content, + String language, + String symbol, + int tokenCount, + String filePath, + int startLine, + int endLine) {} + + private List parseAll(List files, Path root) { + List out = new ArrayList<>(); + MessageDigest sha; + try { + sha = MessageDigest.getInstance("SHA-256"); + } catch (Exception e) { + throw new IllegalStateException(e); + } + for (Path f : files) { + String rel = root.relativize(f).toString().replace('\\', '/'); + try { + List parsed = parser.parse(f, rel); + for (var pc : parsed) { + String hash = bytesToHex(sha.digest(pc.content().getBytes(StandardCharsets.UTF_8))); + sha.reset(); + int tokens = Math.max(1, pc.content().length() / 4); // heuristic; refined if needed + out.add(new ParsedPiece( + hash, pc.content(), pc.language(), pc.symbol(), tokens, rel, pc.startLine(), pc.endLine())); + } + } catch (Exception e) { + log.warn("parse failed for {}: {}", rel, e.toString()); + } + } + return out; + } + + private List embedAll(JobId jobId, List pieces) { + // Dedupe by hash across this batch AND against existing chunks in the store/cache. + Map resolved = new HashMap<>(); + List toEmbed = new ArrayList<>(); + for (var p : pieces) { + if (resolved.containsKey(p.contentHash())) continue; + var existing = chunkStore.findByContentHash(p.contentHash()); + if (existing.isPresent()) { + resolved.put(p.contentHash(), existing.get()); + continue; + } + // cache? + var cached = embeddingCache.get(p.contentHash()); + if (cached.isPresent()) { + Chunk c = upsert(p, cached.get()); + resolved.put(p.contentHash(), c); + continue; + } + toEmbed.add(p); + } + + if (!toEmbed.isEmpty()) { + List texts = toEmbed.stream().map(ParsedPiece::content).toList(); + List vecs = embeddings.embed(texts); + for (int i = 0; i < toEmbed.size(); i++) { + var p = toEmbed.get(i); + float[] v = vecs.get(i); + embeddingCache.put(p.contentHash(), v); + Chunk c = upsert(p, v); + resolved.put(p.contentHash(), c); + } + logEvent(jobId, JobLogEvent.Level.INFO, JobStage.StageName.EMBED, + "embedded " + toEmbed.size() + " new chunks (cache/dedupe hits = " + (pieces.size() - toEmbed.size()) + ")"); + } + return new ArrayList<>(resolved.values()); + } + + private Chunk upsert(ParsedPiece p, float[] vector) { + Chunk c = new Chunk(ChunkId.random(), p.contentHash(), p.content(), p.language(), p.symbol(), p.tokenCount()); + return chunkStore.upsertChunk(c, new Embedding(c.id(), vector)); + } + + private List buildLinks(VersionId versionId, List pieces) { + // Piece → ChunkId requires knowing the chunk id assigned on upsert. + // We re-resolve via findByContentHash — cheap because it's a Term query. + List links = new ArrayList<>(pieces.size()); + Map hashToId = new HashMap<>(); + for (var p : pieces) { + ChunkId id = hashToId.computeIfAbsent(p.contentHash(), h -> + chunkStore.findByContentHash(h).orElseThrow().id()); + links.add(new ChunkVersion(id, versionId, p.filePath(), p.startLine(), p.endLine())); + } + return links; + } + + private String pickParentIndexedTag(Repository repo, Version ver) { + // Most recent previously-indexed version for this repo that isn't this one. + List indexed = repoStore.findVersionsByStatus(repo.id(), VersionStatus.INDEXED); + return indexed.stream() + .filter(v -> !v.id().equals(ver.id())) + .max((a, b) -> a.tag().compareTo(b.tag())) + .map(Version::tag) + .orElse(null); + } + + private static String bytesToHex(byte[] bytes) { + char[] hex = "0123456789abcdef".toCharArray(); + char[] out = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xff; + out[i * 2] = hex[v >>> 4]; + out[i * 2 + 1] = hex[v & 0x0f]; + } + return new String(out); + } +} diff --git a/trueref-application/src/main/java/com/trueref/application/observability/InMemoryJobEventBus.java b/trueref-application/src/main/java/com/trueref/application/observability/InMemoryJobEventBus.java new file mode 100644 index 0000000..1ef95b6 --- /dev/null +++ b/trueref-application/src/main/java/com/trueref/application/observability/InMemoryJobEventBus.java @@ -0,0 +1,71 @@ +package com.trueref.application.observability; + +import com.trueref.domain.model.IngestionJob; +import com.trueref.domain.model.JobId; +import com.trueref.domain.model.JobLogEvent; +import com.trueref.domain.port.out.JobEventBus; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +/** + * In-process publish/subscribe implementation of {@link JobEventBus}. Listeners receive events on + * the publisher's thread; consumers should defer expensive work (e.g. SSE writes) to a virtual + * thread to keep the publisher fast. + */ +public final class InMemoryJobEventBus implements JobEventBus { + + private final CopyOnWriteArrayList> jobListeners = new CopyOnWriteArrayList<>(); + private final Map>> logListeners = new ConcurrentHashMap<>(); + private final CopyOnWriteArrayList> globalLogListeners = new CopyOnWriteArrayList<>(); + + @Override + public void publishJob(IngestionJob job) { + for (Consumer l : jobListeners) { + try { + l.accept(job); + } catch (Exception ignored) { + // listener failures must not break publishing + } + } + } + + @Override + public void publishLog(JobLogEvent event) { + var perJob = logListeners.get(event.jobId()); + if (perJob != null) { + for (Consumer l : perJob) { + try { + l.accept(event); + } catch (Exception ignored) { + } + } + } + for (Consumer l : globalLogListeners) { + try { + l.accept(event); + } catch (Exception ignored) { + } + } + } + + @Override + public AutoCloseable subscribeJobs(Consumer listener) { + jobListeners.add(listener); + return () -> jobListeners.remove(listener); + } + + @Override + public AutoCloseable subscribeLogs(JobId jobId, Consumer listener) { + var list = logListeners.computeIfAbsent(jobId, k -> new CopyOnWriteArrayList<>()); + list.add(listener); + return () -> list.remove(listener); + } + + /** Subscribe to ALL log events regardless of job (used by the dashboard). */ + public AutoCloseable subscribeAllLogs(Consumer listener) { + globalLogListeners.add(listener); + return () -> globalLogListeners.remove(listener); + } +} diff --git a/trueref-application/src/main/java/com/trueref/application/observability/JobObservationService.java b/trueref-application/src/main/java/com/trueref/application/observability/JobObservationService.java new file mode 100644 index 0000000..0715a49 --- /dev/null +++ b/trueref-application/src/main/java/com/trueref/application/observability/JobObservationService.java @@ -0,0 +1,47 @@ +package com.trueref.application.observability; + +import com.trueref.domain.model.IngestionJob; +import com.trueref.domain.model.JobId; +import com.trueref.domain.model.JobLogEvent; +import com.trueref.domain.model.JobStatus; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.VersionId; +import com.trueref.domain.port.in.ObserveJobs; +import com.trueref.domain.port.out.JobEventBus; +import com.trueref.domain.port.out.JobStore; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + +public final class JobObservationService implements ObserveJobs { + + private final JobStore jobs; + private final JobEventBus bus; + + public JobObservationService(JobStore jobs, JobEventBus bus) { + this.jobs = jobs; + this.bus = bus; + } + + @Override + public Optional findJob(JobId id) { + return jobs.findById(id); + } + + @Override + public List listJobs( + @Nullable RepositoryId repoId, @Nullable VersionId versionId, @Nullable JobStatus status, int limit) { + return jobs.find(repoId, versionId, status, limit); + } + + @Override + public AutoCloseable subscribeJobs(Consumer listener) { + return bus.subscribeJobs(listener); + } + + @Override + public AutoCloseable subscribeLogs(JobId jobId, Consumer listener) { + return bus.subscribeLogs(jobId, listener); + } +} diff --git a/trueref-application/src/main/java/com/trueref/application/observability/package-info.java b/trueref-application/src/main/java/com/trueref/application/observability/package-info.java new file mode 100644 index 0000000..39137a6 --- /dev/null +++ b/trueref-application/src/main/java/com/trueref/application/observability/package-info.java @@ -0,0 +1,3 @@ +/** In-process implementations of cross-cutting application services. */ +@org.jspecify.annotations.NullMarked +package com.trueref.application.observability; diff --git a/trueref-application/src/main/java/com/trueref/application/package-info.java b/trueref-application/src/main/java/com/trueref/application/package-info.java new file mode 100644 index 0000000..1069a9c --- /dev/null +++ b/trueref-application/src/main/java/com/trueref/application/package-info.java @@ -0,0 +1,3 @@ +/** Application services: use-case implementations. */ +@org.jspecify.annotations.NullMarked +package com.trueref.application; diff --git a/trueref-application/src/main/java/com/trueref/application/resolve/LibraryResolver.java b/trueref-application/src/main/java/com/trueref/application/resolve/LibraryResolver.java new file mode 100644 index 0000000..6025f50 --- /dev/null +++ b/trueref-application/src/main/java/com/trueref/application/resolve/LibraryResolver.java @@ -0,0 +1,161 @@ +package com.trueref.application.resolve; + +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.TagPattern; +import com.trueref.domain.model.Version; +import com.trueref.domain.model.VersionStatus; +import com.trueref.domain.port.in.IndexVersion; +import com.trueref.domain.port.in.ResolveLibraryId; +import com.trueref.domain.port.out.RepositoryStore; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Fuzzy library-name matching + version→tag mapping. Mirrors Context7's {@code resolve-library-id} + * semantics. When {@code version} is provided and maps to a known-but-not-yet-indexed tag, triggers + * an async index job (fire-and-forget). + */ +public final class LibraryResolver implements ResolveLibraryId { + + private static final Logger log = LoggerFactory.getLogger(LibraryResolver.class); + + private static final Pattern SEMVER = Pattern.compile("^v?(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?.*$"); + + private final RepositoryStore store; + private final IndexVersion indexer; + + public LibraryResolver(RepositoryStore store, IndexVersion indexer) { + this.store = store; + this.indexer = indexer; + } + + @Override + public Result resolve(Query q) { + String needle = q.libraryName().toLowerCase(); + List all = store.findAll(); + List matches = new ArrayList<>(); + for (Repository r : all) { + double score = nameScore(r.name().toLowerCase(), needle); + if (score <= 0) continue; + List versions = store.findVersionsByRepo(r.id()); + + // If a version was requested, map it to a tag and ensure indexing. + if (q.version() != null && !q.version().isBlank()) { + Optional target = mapVersion(r, versions, q.version()); + target.ifPresent(v -> ensureIndexed(r.id(), v)); + } + + int snippetCount = versions.stream() + .filter(v -> v.status() == VersionStatus.INDEXED) + .mapToInt(Version::chunkCount) + .sum(); + List refs = versions.stream() + .sorted(Comparator.comparing(Version::tag).reversed()) + .map(v -> new VersionRef(v.id(), v.tag(), v.status())) + .toList(); + String libraryId = "/" + r.name(); + matches.add(new Match(r.id(), libraryId, r.name(), null, snippetCount, refs, score)); + } + matches.sort(Comparator.comparingDouble(Match::score).reversed()); + return new Result(matches); + } + + /** Fuzzy name scoring: exact 1.0, prefix 0.9, contains 0.7, token overlap otherwise. */ + private double nameScore(String haystack, String needle) { + if (haystack.equals(needle)) return 1.0; + if (haystack.endsWith("/" + needle) || haystack.startsWith(needle + "/")) return 0.95; + if (haystack.contains(needle)) return 0.8; + // token overlap + String[] hTok = haystack.split("[^a-z0-9]+"); + String[] nTok = needle.split("[^a-z0-9]+"); + int hit = 0; + for (String nt : nTok) { + if (nt.isBlank()) continue; + for (String ht : hTok) { + if (ht.equals(nt)) { hit++; break; } + } + } + if (hit == 0) return 0.0; + return 0.3 + 0.4 * ((double) hit / Math.max(1, nTok.length)); + } + + /** + * Maps a version string to the closest matching tag using the repo's configured mapping rules. + * Rules are tried in order. + */ + public Optional mapVersion(Repository repo, List versions, String requested) { + for (TagPattern rule : repo.versionMappingRules()) { + String candidate = switch (rule) { + case TagPattern.Exact e -> requested; + case TagPattern.VPrefix v -> "v" + stripV(requested); + case TagPattern.ReleasePrefix r -> "release-" + stripV(requested); + case TagPattern.Custom c -> c.template() + .replace("{version}", requested) + .replace("{semver}", stripV(requested)); + case TagPattern.SemverFuzzy s -> null; // handled below + }; + if (candidate == null) continue; + Optional exact = versions.stream() + .filter(v -> v.tag().equalsIgnoreCase(candidate)) + .findFirst(); + if (exact.isPresent()) return exact; + } + // Semver fuzzy: pick tag with closest semver distance + return semverClosest(versions, requested); + } + + private Optional semverClosest(List versions, String requested) { + int[] r = parseSemver(requested); + if (r == null) return Optional.empty(); + return versions.stream() + .map(v -> new Object[] {v, parseSemver(v.tag())}) + .filter(t -> t[1] != null) + .min(Comparator.comparingLong(t -> semverDist((int[]) t[1], r))) + .map(t -> (Version) t[0]); + } + + private static @Nullable int[] parseSemver(String s) { + Matcher m = SEMVER.matcher(s); + if (!m.matches()) return null; + return new int[] { + parseIntOrZero(m.group(1)), + parseIntOrZero(m.group(2)), + parseIntOrZero(m.group(3)) + }; + } + + private static int parseIntOrZero(String s) { + if (s == null || s.isEmpty()) return 0; + try { return Integer.parseInt(s); } catch (NumberFormatException e) { return 0; } + } + + private static long semverDist(int[] a, int[] b) { + long d = 0; + d += Math.abs(a[0] - b[0]) * 1_000_000L; + d += Math.abs(a[1] - b[1]) * 1_000L; + d += Math.abs(a[2] - b[2]); + return d; + } + + private static String stripV(String s) { + return s.startsWith("v") || s.startsWith("V") ? s.substring(1) : s; + } + + private void ensureIndexed(RepositoryId repoId, Version v) { + if (v.status() == VersionStatus.INDEXED || v.status() == VersionStatus.INDEXING) return; + try { + log.info("on-demand indexing: repo={} tag={}", repoId, v.tag()); + indexer.enqueue(repoId, v.id(), false); + } catch (Exception e) { + log.warn("on-demand indexing enqueue failed: {}", e.toString()); + } + } +} diff --git a/trueref-application/src/main/java/com/trueref/application/search/HybridSearchService.java b/trueref-application/src/main/java/com/trueref/application/search/HybridSearchService.java new file mode 100644 index 0000000..d9a8b38 --- /dev/null +++ b/trueref-application/src/main/java/com/trueref/application/search/HybridSearchService.java @@ -0,0 +1,238 @@ +package com.trueref.application.search; + +import com.trueref.domain.error.InvalidSearchRequest; +import com.trueref.domain.model.ChunkId; +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.SearchHit; +import com.trueref.domain.model.SearchScope; +import com.trueref.domain.model.Version; +import com.trueref.domain.port.in.SearchLibraryDocs; +import com.trueref.domain.port.out.ChunkStore; +import com.trueref.domain.port.out.EmbeddingService; +import com.trueref.domain.port.out.RepositoryStore; +import com.trueref.domain.port.out.RerankerService; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Hybrid search: BM25 + dense kNN fused by Reciprocal Rank Fusion (RRF), then reranked by a + * cross-encoder, then packed to a token budget. + */ +public final class HybridSearchService implements SearchLibraryDocs { + + private static final Logger log = LoggerFactory.getLogger(HybridSearchService.class); + + /** + * Matches camelCase identifiers that are likely to be Phaser API method/class names (≥6 chars, + * must contain at least one uppercase letter after the first char, not all-caps). + * Examples: setCollideWorldBounds, createBitmapMask, addOverlap. + */ + private static final Pattern CAMEL_IDENT = Pattern.compile( + "\\b([a-z][a-zA-Z0-9]{5,})(?=\\b)"); + + private final ChunkStore chunks; + private final EmbeddingService embedder; + private final RerankerService reranker; + private final RepositoryStore repos; + private final int rrfK; + private final int rerankTopK; + private final int finalTopK; + + public HybridSearchService( + ChunkStore chunks, + EmbeddingService embedder, + RerankerService reranker, + RepositoryStore repos, + int rrfK, + int rerankTopK, + int finalTopK) { + this.chunks = chunks; + this.embedder = embedder; + this.reranker = reranker; + this.repos = repos; + this.rrfK = rrfK; + this.rerankTopK = rerankTopK; + this.finalTopK = finalTopK; + } + + @Override + public Result search(Query q) { + if (q.text() == null || q.text().isBlank()) { + throw new InvalidSearchRequest("query text must not be blank"); + } + if (q.scope().refs().isEmpty()) { + throw new InvalidSearchRequest("search scope must not be empty"); + } + + String text = rewrite(q.text(), q.topic()); + // Augment BM25 query with camelCase identifiers found in the text so that the exact + // method-name chunk scores higher in BM25 even when it competes with generic mentions. + String bm25Text = augmentWithCamelIdents(text); + + List bm25 = chunks.bm25Search(bm25Text, q.scope(), rerankTopK); + float[] vec = embedder.embed(List.of(text)).get(0); + List dense = chunks.denseSearch(vec, q.scope(), rerankTopK); + + List fused = rrf(bm25, dense); + if (fused.size() > rerankTopK) fused = fused.subList(0, rerankTopK); + + // Demote changelog / synthetic-skill / docs paths before the reranker sees them so that + // authoritative source-code chunks aren't squeezed out by historical migration notes. + List biased = applyFilePathBias(fused); + + // Enrich with repo name + tag (ChunkStore leaves these empty). + List enriched = enrich(biased); + + List reranked = reranker.rerank(text, enriched); + + List packed = packByTokenBudget(reranked, q.tokensBudget(), q.maxHits() > 0 ? q.maxHits() : finalTopK); + int totalTokens = packed.stream().mapToInt(h -> estimateTokens(h.content())).sum(); + return new Result(packed, totalTokens); + } + + /* ------------------------------------------------------------------ */ + + private String rewrite(String text, String topic) { + String base = text.trim(); + if (topic != null && !topic.isBlank()) { + return base + " " + topic.trim(); + } + return base; + } + + /** + * Returns a copy of {@code text} with each camelCase identifier repeated at the end (once). + * This lifts their BM25 term-frequency contribution without altering the semantic meaning + * used for the dense embedding query. + * + *

Example: "how to use setCollideWorldBounds" → + * "how to use setCollideWorldBounds setCollideWorldBounds" + */ + private static String augmentWithCamelIdents(String text) { + Matcher m = CAMEL_IDENT.matcher(text); + StringBuilder extra = new StringBuilder(); + while (m.find()) { + String ident = m.group(1); + // Only repeat identifiers that contain at least one uppercase letter + // (filters out short common words like "should", "create"). + if (!ident.equals(ident.toLowerCase())) { + extra.append(' ').append(ident); + } + } + return extra.isEmpty() ? text : text + extra; + } + + /** + * Applies a path-based multiplier to RRF scores before handing candidates to the reranker. + * Changelogs and synthetic skill docs are semantically relevant but tend to outrank the + * authoritative source-code chunks when the query mentions API migration or breaking changes. + * Demoting them here keeps them retrievable while giving source files priority. + * + *

Multipliers (tuned against the phaser_rag_eval suite): + *

    + *
  • {@code changelog/} → ×0.50 — migration notes, not current API reference + *
  • {@code skills/} / {@code SKILL.md} → ×0.60 — synthetic summaries, not authoritative + *
  • {@code docs/} → ×0.75 — curated docs; useful but prefer source JSDoc + *
  • everything else (source, tests, configs) → ×1.0 + *
+ */ + private static List applyFilePathBias(List hits) { + boolean anyChanged = false; + List out = new ArrayList<>(hits.size()); + for (SearchHit h : hits) { + double mult = filePathMultiplier(h.filePath()); + if (mult == 1.0) { + out.add(h); + } else { + out.add(new SearchHit( + h.chunkId(), h.repoId(), h.versionId(), h.repoName(), h.tag(), + h.filePath(), h.startLine(), h.endLine(), h.language(), h.symbol(), + h.content(), h.score() * mult)); + anyChanged = true; + } + } + if (!anyChanged) return hits; + out.sort(Comparator.comparingDouble(SearchHit::score).reversed()); + return out; + } + + private static double filePathMultiplier(String filePath) { + if (filePath == null || filePath.isEmpty()) return 1.0; + String lp = filePath.toLowerCase(); + if (lp.startsWith("changelog/") || lp.contains("/changelog/")) return 0.50; + if (lp.contains("/skills/") || lp.endsWith("skill.md")) return 0.60; + if (lp.startsWith("docs/") || lp.contains("/docs/")) return 0.75; + return 1.0; + } + + private List rrf(List a, List b) { + Map scores = new HashMap<>(); + Map firstSeen = new HashMap<>(); + addRankContribution(a, scores, firstSeen); + addRankContribution(b, scores, firstSeen); + return scores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .map(e -> { + SearchHit h = firstSeen.get(e.getKey()); + return new SearchHit( + h.chunkId(), h.repoId(), h.versionId(), h.repoName(), h.tag(), + h.filePath(), h.startLine(), h.endLine(), h.language(), h.symbol(), + h.content(), e.getValue()); + }) + .toList(); + } + + private void addRankContribution(List hits, Map scores, Map seen) { + for (int rank = 0; rank < hits.size(); rank++) { + SearchHit h = hits.get(rank); + scores.merge(h.chunkId(), 1.0 / (rrfK + rank + 1.0), Double::sum); + seen.putIfAbsent(h.chunkId(), h); + } + } + + private List enrich(List hits) { + Map repoNameByRepoId = new HashMap<>(); + Map tagByVersionId = new HashMap<>(); + List out = new ArrayList<>(hits.size()); + for (SearchHit h : hits) { + String repoName = repoNameByRepoId.computeIfAbsent( + h.repoId().toString(), + k -> repos.findById(h.repoId()).map(Repository::name).orElse("?")); + String tag = tagByVersionId.computeIfAbsent( + h.versionId().toString(), + k -> repos.findVersion(h.versionId()).map(Version::tag).orElse("?")); + out.add(new SearchHit( + h.chunkId(), h.repoId(), h.versionId(), + repoName, tag, + h.filePath(), h.startLine(), h.endLine(), h.language(), h.symbol(), + h.content(), h.score())); + } + return out; + } + + private List packByTokenBudget(List ranked, int tokenBudget, int maxHits) { + List out = new ArrayList<>(); + int used = 0; + for (SearchHit h : ranked) { + if (out.size() >= maxHits) break; + int t = estimateTokens(h.content()); + if (used + t > tokenBudget && !out.isEmpty()) break; + out.add(h); + used += t; + } + return out; + } + + /** 4 chars ≈ 1 token — same rule of thumb Context7 uses for packing. */ + private static int estimateTokens(String s) { + return Math.max(1, s.length() / 4); + } +} diff --git a/trueref-bootstrap/pom.xml b/trueref-bootstrap/pom.xml new file mode 100644 index 0000000..a88fd1e --- /dev/null +++ b/trueref-bootstrap/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + com.trueref + trueref-parent + 0.1.0-SNAPSHOT + + + trueref-bootstrap + trueref-bootstrap + Spring Boot entry point. Wires beans across modules. Produces the executable fat JAR. + + + + com.trueref + trueref-domain + + + com.trueref + trueref-application + + + com.trueref + trueref-adapters + + + com.trueref + trueref-frontend + + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + trueref + + + org.springframework.boot + spring-boot-maven-plugin + + com.trueref.bootstrap.TrueRefApplication + + + + repackage + + + + + + diff --git a/trueref-bootstrap/src/main/java/com/trueref/bootstrap/ApplicationBeans.java b/trueref-bootstrap/src/main/java/com/trueref/bootstrap/ApplicationBeans.java new file mode 100644 index 0000000..c3c2b76 --- /dev/null +++ b/trueref-bootstrap/src/main/java/com/trueref/bootstrap/ApplicationBeans.java @@ -0,0 +1,89 @@ +package com.trueref.bootstrap; + +import com.trueref.application.catalog.CatalogService; +import com.trueref.application.ingest.DiscoveryService; +import com.trueref.application.ingest.IngestionOrchestrator; +import com.trueref.application.observability.InMemoryJobEventBus; +import com.trueref.application.observability.JobObservationService; +import com.trueref.application.resolve.LibraryResolver; +import com.trueref.application.search.HybridSearchService; +import com.trueref.domain.port.out.ChunkStore; +import com.trueref.domain.port.out.CodeParser; +import com.trueref.domain.port.out.EmbeddingCache; +import com.trueref.domain.port.out.EmbeddingService; +import com.trueref.domain.port.out.GitClient; +import com.trueref.domain.port.out.JobStore; +import com.trueref.domain.port.out.RepositoryStore; +import com.trueref.domain.port.out.RerankerService; +import java.nio.file.Path; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Explicit bean wiring for the application layer (which stays Spring-annotation-free). + * We expose only concrete beans; Spring resolves interface dependencies against the single + * concrete implementation. + */ +@Configuration +public class ApplicationBeans { + + @Bean + Path trueRefHome(@Value("${trueref.home:./data}") String home) { + return Path.of(home); + } + + @Bean + InMemoryJobEventBus jobEventBus() { + return new InMemoryJobEventBus(); + } + + @Bean + CatalogService catalogService(RepositoryStore store, Path trueRefHome) { + return new CatalogService(store, trueRefHome); + } + + @Bean + DiscoveryService discoveryService(RepositoryStore store, GitClient git) { + return new DiscoveryService(store, git); + } + + @Bean(destroyMethod = "shutdown") + IngestionOrchestrator ingestionOrchestrator( + RepositoryStore repoStore, + JobStore jobStore, + ChunkStore chunkStore, + EmbeddingService embeddings, + EmbeddingCache embeddingCache, + GitClient git, + CodeParser parser, + InMemoryJobEventBus bus, + @Value("${trueref.ingestion.max-parse-jobs:4}") int maxParseJobs, + @Value("${trueref.ingestion.embed-queue-capacity:4}") int embedQueueCapacity) { + return new IngestionOrchestrator( + repoStore, jobStore, chunkStore, embeddings, embeddingCache, git, parser, bus, + maxParseJobs, embedQueueCapacity); + } + + @Bean + LibraryResolver libraryResolver(RepositoryStore store, IngestionOrchestrator indexer) { + return new LibraryResolver(store, indexer); + } + + @Bean + HybridSearchService hybridSearchService( + ChunkStore chunks, + EmbeddingService embedder, + RerankerService reranker, + RepositoryStore repos, + @Value("${trueref.search.rrf-k:60}") int rrfK, + @Value("${trueref.reranker.top-k:50}") int rerankTopK, + @Value("${trueref.search.final-top-k:20}") int finalTopK) { + return new HybridSearchService(chunks, embedder, reranker, repos, rrfK, rerankTopK, finalTopK); + } + + @Bean + JobObservationService jobObservationService(JobStore jobs, InMemoryJobEventBus bus) { + return new JobObservationService(jobs, bus); + } +} diff --git a/trueref-bootstrap/src/main/java/com/trueref/bootstrap/ScheduledPoller.java b/trueref-bootstrap/src/main/java/com/trueref/bootstrap/ScheduledPoller.java new file mode 100644 index 0000000..3fdbca5 --- /dev/null +++ b/trueref-bootstrap/src/main/java/com/trueref/bootstrap/ScheduledPoller.java @@ -0,0 +1,57 @@ +package com.trueref.bootstrap; + +import com.trueref.application.ingest.DiscoveryService; +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.Version; +import com.trueref.domain.model.VersionStatus; +import com.trueref.domain.port.in.IndexVersion; +import com.trueref.domain.port.out.RepositoryStore; +import java.time.Instant; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** Periodically fetches tags for registered repos and enqueues indexing for new ones. */ +@Component +@EnableScheduling +@ConditionalOnProperty(name = "trueref.ingestion.poller-enabled", havingValue = "true", matchIfMissing = true) +public class ScheduledPoller { + + private static final Logger log = LoggerFactory.getLogger(ScheduledPoller.class); + + private final RepositoryStore repoStore; + private final DiscoveryService discovery; + private final IndexVersion indexer; + + public ScheduledPoller(RepositoryStore repoStore, DiscoveryService discovery, IndexVersion indexer) { + this.repoStore = repoStore; + this.discovery = discovery; + this.indexer = indexer; + } + + @Scheduled(fixedDelayString = "${trueref.ingestion.poll-interval-default:PT1H}") + public void pollAll() { + Instant start = Instant.now(); + int scanned = 0; + int enqueued = 0; + for (Repository repo : repoStore.findAll()) { + try { + discovery.discover(repo.id()); + for (Version v : repoStore.findVersionsByRepo(repo.id())) { + if (v.status() == VersionStatus.DISCOVERED) { + indexer.enqueue(repo.id(), v.id(), false); + enqueued++; + } + } + scanned++; + } catch (Exception e) { + log.warn("poll failed for repo={}: {}", repo.name(), e.toString()); + } + } + log.info("poll completed in {}ms: repos scanned={} jobs enqueued={}", + java.time.Duration.between(start, Instant.now()).toMillis(), scanned, enqueued); + } +} diff --git a/trueref-bootstrap/src/main/java/com/trueref/bootstrap/StaleJobCleanupStartup.java b/trueref-bootstrap/src/main/java/com/trueref/bootstrap/StaleJobCleanupStartup.java new file mode 100644 index 0000000..a68066f --- /dev/null +++ b/trueref-bootstrap/src/main/java/com/trueref/bootstrap/StaleJobCleanupStartup.java @@ -0,0 +1,59 @@ +package com.trueref.bootstrap; + +import com.trueref.domain.port.out.JobStore; +import com.trueref.domain.port.out.RepositoryStore; +import java.time.Instant; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * On-startup cleanup for stale job state left by a previous crash or SIGKILL. + * + *

Any job that is RUNNING or QUEUED when the application starts must have been orphaned by a + * previous JVM exit (clean or unclean). We fail them all atomically before accepting traffic so + * the UI and API never show phantom RUNNING jobs. Matching INDEXING versions are also reset to + * FAILED so they can be re-queued immediately. + * + *

This fires after Flyway migrations and all beans are initialised, but before the + * application starts accepting HTTP requests (ApplicationReadyEvent fires before the embedded + * Tomcat connector starts accepting connections). + */ +@Component +class StaleJobCleanupStartup { + + private static final Logger log = LoggerFactory.getLogger(StaleJobCleanupStartup.class); + + private static final String RESTART_REASON = "interrupted by server restart"; + + private final JobStore jobStore; + private final RepositoryStore repositoryStore; + + StaleJobCleanupStartup(JobStore jobStore, RepositoryStore repositoryStore) { + this.jobStore = jobStore; + this.repositoryStore = repositoryStore; + } + + @EventListener(ApplicationReadyEvent.class) + public void cleanupStaleJobs() { + Instant now = Instant.now(); + + int failedJobs = jobStore.failStaleJobs(now); + if (failedJobs > 0) { + log.warn( + "Startup cleanup: marked {} orphaned job(s) as FAILED (were RUNNING or QUEUED at shutdown).", + failedJobs); + } else { + log.info("Startup cleanup: no stale jobs found."); + } + + int failedVersions = repositoryStore.failStaleIndexingVersions(RESTART_REASON); + if (failedVersions > 0) { + log.warn( + "Startup cleanup: reset {} INDEXING version(s) to FAILED (their jobs did not complete).", + failedVersions); + } + } +} diff --git a/trueref-bootstrap/src/main/java/com/trueref/bootstrap/TrueRefApplication.java b/trueref-bootstrap/src/main/java/com/trueref/bootstrap/TrueRefApplication.java new file mode 100644 index 0000000..35f561f --- /dev/null +++ b/trueref-bootstrap/src/main/java/com/trueref/bootstrap/TrueRefApplication.java @@ -0,0 +1,17 @@ +package com.trueref.bootstrap; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Trueref entry point. The only place where Spring component scanning is allowed across the + * {@code com.trueref} package tree. Adapters and application modules expose explicit + * {@code @Configuration} classes that this class imports via component scanning. + */ +@SpringBootApplication(scanBasePackages = "com.trueref") +public class TrueRefApplication { + + public static void main(String[] args) { + SpringApplication.run(TrueRefApplication.class, args); + } +} diff --git a/trueref-bootstrap/src/main/resources/application.yml b/trueref-bootstrap/src/main/resources/application.yml new file mode 100644 index 0000000..f771192 --- /dev/null +++ b/trueref-bootstrap/src/main/resources/application.yml @@ -0,0 +1,114 @@ +spring: + application: + name: trueref + threads: + virtual: + enabled: true + datasource: + url: jdbc:h2:file:${trueref.home:./data}/h2/trueref;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MV_STORE=TRUE + username: sa + password: "" + driver-class-name: org.h2.Driver + hikari: + # Embedded H2 serialises writes internally; 8 connections is ample for virtual-thread + # workloads. 32 is wasteful and causes unnecessary H2 lock contention. + maximum-pool-size: 8 + minimum-idle: 2 + flyway: + enabled: true + locations: classpath:db/migration + mvc: + async: + request-timeout: 0 # SSE streams must not time out + # Spring AI MCP server. In Spring AI 1.0.0 the WebMVC transport is SSE-based + # (WebMvcSseServerTransportProvider) — the closest available transport to the 2025-03-26 + # "Streamable HTTP" spec; there is no separate "protocol: streamable" property in this + # starter. JSON-RPC POSTs land on `sse-message-endpoint` (/mcp); server-initiated + # notifications stream over `sse-endpoint` (/sse). See com.trueref.adapter.in.mcp. + ai: + mcp: + server: + enabled: true + name: trueref + version: 0.1.0 + type: SYNC + sse-message-endpoint: /mcp + sse-endpoint: /sse + +server: + port: 8080 + shutdown: graceful + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + +trueref: + home: ${TRUEREF_HOME:./data} + ingestion: + poll-interval-default: PT1H + tag-cap-default: 100 + max-file-size-bytes-default: 1048576 + watched-folder: ${trueref.home}/watched + # Max parallel parse jobs (FETCH/CLONE → CHECKOUT → DISCOVER → DIFF → PARSE). + # Parse is I/O + CPU only — no GPU. 4 is safe on this machine (Ryzen 9 3900X, 62 GB RAM); + # increase for repos with small files, decrease if git I/O saturates disk. + max-parse-jobs: 4 + # Max parsed batches buffered between parse workers and the embed worker. + # When the embed worker is busy, parse workers block here — natural backpressure. + # Total peak in-memory batches = max-parse-jobs + embed-queue-capacity. + embed-queue-capacity: 4 + embedding: + model: bge-base-en-v1.5 + onnx-providers: cuda,directml,cpu + session-count: 1 + batch-size: 32 + max-seq-len: 512 + # Which CUDA device to bind ONNX sessions to. Passed directly to ORT's CUDA EP + # as the physical device index — ORT uses the CUDA driver/NVML API which can bypass + # CUDA_VISIBLE_DEVICES remapping. The ./trueref script sets this to $TRUEREF_GPU (default: 1 = RTX 3060). + gpu-device-id: 0 + # Per-session GPU memory cap in bytes. 0 = unbounded. With session-count=1 there + # is no pool contention, so leave this unbounded — capping it risks exhausting the + # BFC arena during model-weight loading before inference starts. The ./trueref script + # defaults to 0 and can be overridden with TRUEREF_MEM_LIMIT. + gpu-mem-limit-bytes: 0 + # Override download URLs per (model, file). The built-in defaults (in ModelDownloader) + # cover bge-base-en-v1.5, ms-marco-MiniLM-L6-v2, bge-m3, and bge-reranker-v2-m3. + # Set HF_TOKEN in the environment for higher rate limits or gated models. + # model-sources: + # bge-base-en-v1.5: + # model.onnx: + # - https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/onnx/model.onnx + reranker: + model: ms-marco-MiniLM-L6-v2 + top-k: 100 + embedding-cache: + # Must match the embedding model's output dimension. Changing this automatically + # wipes the stale .f32 files in the cache directory on next startup. + dimension: 768 + search: + rrf-k: 60 + final-top-k: 20 + mcp: + tokens-default: 5000 + tokens-min: 500 + tokens-max: 50000 + +logging: + level: + root: INFO + com.trueref: INFO + org.eclipse.jgit: WARN + org.apache.lucene: WARN diff --git a/trueref-bootstrap/src/main/scripts/trueref b/trueref-bootstrap/src/main/scripts/trueref new file mode 100755 index 0000000..d728f09 --- /dev/null +++ b/trueref-bootstrap/src/main/scripts/trueref @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# trueref launcher — wraps the fat JAR with the JVM flags required to silence +# the FFM (foreign linker) restricted-method warning emitted by JNA-based +# tokenizer libraries and to make Lucene's Vector API path readable. +# +# --enable-native-access=ALL-UNNAMED +# Lucene 10 + DJL HuggingFace Tokenizers use the new java.lang.foreign +# Linker API; on Java 21 this requires explicit native-access opt-in. +# --add-modules jdk.incubator.vector +# Lucene 10 ships an incubator-vector codepath that is significantly +# faster for cosine/dot-product math but only loads if the module is +# made readable from the unnamed module. +# +# Usage: +# bin/trueref # default settings +# bin/trueref --server.port=18080 # forward Spring properties +# TRUEREF_JAR=/path/to/trueref.jar bin/trueref +# +# Environment overrides: +# TRUEREF_JAR Path to the fat JAR (default: /../trueref.jar) +# JAVA Path to the java binary (default: ${JAVA_HOME:-}/bin/java or `java` on PATH) +# JAVA_OPTS Extra JVM flags (e.g. -Xmx16g, -XX:+UseZGC) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JAR_DEFAULT="${SCRIPT_DIR}/../trueref.jar" +JAR="${TRUEREF_JAR:-$JAR_DEFAULT}" + +if [[ ! -f "$JAR" ]]; then + echo "trueref: jar not found at $JAR" >&2 + echo "trueref: set TRUEREF_JAR or place trueref.jar next to this script" >&2 + exit 1 +fi + +if [[ -n "${JAVA:-}" ]]; then + : +elif [[ -n "${JAVA_HOME:-}" && -x "${JAVA_HOME}/bin/java" ]]; then + JAVA="${JAVA_HOME}/bin/java" +else + JAVA="$(command -v java || true)" +fi + +if [[ -z "${JAVA:-}" || ! -x "${JAVA}" ]]; then + echo "trueref: java not found; set JAVA_HOME or install JDK 21+" >&2 + exit 1 +fi + +# ONNX Runtime CUDA EP needs cuDNN 9 on LD_LIBRARY_PATH. Many distros only ship +# cuDNN via the system package manager or via a Python wheel (nvidia-cudnn-cu12). +# If the user sets TRUEREF_CUDNN_LIB we trust it; otherwise we leave LD_LIBRARY_PATH +# alone and let CUDA fall back to CPU with a logged warning. +if [[ -n "${TRUEREF_CUDNN_LIB:-}" ]]; then + export LD_LIBRARY_PATH="${TRUEREF_CUDNN_LIB}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +fi + +exec "$JAVA" \ + --enable-native-access=ALL-UNNAMED \ + --add-modules=jdk.incubator.vector \ + ${JAVA_OPTS:-} \ + -jar "$JAR" \ + "$@" diff --git a/trueref-domain/pom.xml b/trueref-domain/pom.xml new file mode 100644 index 0000000..59a3bfb --- /dev/null +++ b/trueref-domain/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + + com.trueref + trueref-parent + 0.1.0-SNAPSHOT + + + trueref-domain + trueref-domain + Pure domain model + ports. No Spring, no I/O, no third-party libs beyond JSpecify. + + + + + + diff --git a/trueref-domain/src/main/java/com/trueref/domain/error/IngestionFailed.java b/trueref-domain/src/main/java/com/trueref/domain/error/IngestionFailed.java new file mode 100644 index 0000000..bec2739 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/error/IngestionFailed.java @@ -0,0 +1,9 @@ +package com.trueref.domain.error; + +import org.jspecify.annotations.Nullable; + +public final class IngestionFailed extends TrueRefException { + public IngestionFailed(String message, @Nullable Throwable cause) { + super("ingestion_failed", message, cause); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/error/InvalidSearchRequest.java b/trueref-domain/src/main/java/com/trueref/domain/error/InvalidSearchRequest.java new file mode 100644 index 0000000..2528801 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/error/InvalidSearchRequest.java @@ -0,0 +1,7 @@ +package com.trueref.domain.error; + +public final class InvalidSearchRequest extends TrueRefException { + public InvalidSearchRequest(String message) { + super("invalid_search_request", message, null); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/error/RepositoryAlreadyRegistered.java b/trueref-domain/src/main/java/com/trueref/domain/error/RepositoryAlreadyRegistered.java new file mode 100644 index 0000000..e261436 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/error/RepositoryAlreadyRegistered.java @@ -0,0 +1,7 @@ +package com.trueref.domain.error; + +public final class RepositoryAlreadyRegistered extends TrueRefException { + public RepositoryAlreadyRegistered(String name) { + super("repository_already_registered", "Repository already registered: " + name, null); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/error/RepositoryNotFound.java b/trueref-domain/src/main/java/com/trueref/domain/error/RepositoryNotFound.java new file mode 100644 index 0000000..c868d31 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/error/RepositoryNotFound.java @@ -0,0 +1,7 @@ +package com.trueref.domain.error; + +public final class RepositoryNotFound extends TrueRefException { + public RepositoryNotFound(String idOrName) { + super("repository_not_found", "Repository not found: " + idOrName, null); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/error/TagNotFound.java b/trueref-domain/src/main/java/com/trueref/domain/error/TagNotFound.java new file mode 100644 index 0000000..6ad8cfe --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/error/TagNotFound.java @@ -0,0 +1,7 @@ +package com.trueref.domain.error; + +public final class TagNotFound extends TrueRefException { + public TagNotFound(String repo, String tag) { + super("tag_not_found", "Tag not found in repository: " + repo + "@" + tag, null); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/error/TrueRefException.java b/trueref-domain/src/main/java/com/trueref/domain/error/TrueRefException.java new file mode 100644 index 0000000..4c53b5d --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/error/TrueRefException.java @@ -0,0 +1,25 @@ +package com.trueref.domain.error; + +import org.jspecify.annotations.Nullable; + +/** Root of all domain errors. Carries a stable string {@link #code()} for client localization. */ +public abstract sealed class TrueRefException extends RuntimeException + permits RepositoryAlreadyRegistered, + RepositoryNotFound, + VersionNotFound, + VersionNotIndexed, + TagNotFound, + IngestionFailed, + InvalidSearchRequest { + + private final String code; + + protected TrueRefException(String code, String message, @Nullable Throwable cause) { + super(message, cause); + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/error/VersionNotFound.java b/trueref-domain/src/main/java/com/trueref/domain/error/VersionNotFound.java new file mode 100644 index 0000000..66923bf --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/error/VersionNotFound.java @@ -0,0 +1,7 @@ +package com.trueref.domain.error; + +public final class VersionNotFound extends TrueRefException { + public VersionNotFound(String repo, String version) { + super("version_not_found", "Version not found: " + repo + "@" + version, null); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/error/VersionNotIndexed.java b/trueref-domain/src/main/java/com/trueref/domain/error/VersionNotIndexed.java new file mode 100644 index 0000000..4a5d047 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/error/VersionNotIndexed.java @@ -0,0 +1,8 @@ +package com.trueref.domain.error; + +/** Thrown when a search request targets a known version that has not been indexed yet. */ +public final class VersionNotIndexed extends TrueRefException { + public VersionNotIndexed(String repo, String version) { + super("version_not_indexed", "Version not yet indexed: " + repo + "@" + version, null); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/error/package-info.java b/trueref-domain/src/main/java/com/trueref/domain/error/package-info.java new file mode 100644 index 0000000..60d4c36 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/error/package-info.java @@ -0,0 +1,5 @@ +/** + * Sealed exception hierarchy for the domain. Adapters translate these to HTTP / JSON-RPC responses. + */ +@org.jspecify.annotations.NullMarked +package com.trueref.domain.error; diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/Chunk.java b/trueref-domain/src/main/java/com/trueref/domain/model/Chunk.java new file mode 100644 index 0000000..4e7c214 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/Chunk.java @@ -0,0 +1,18 @@ +package com.trueref.domain.model; + +import org.jspecify.annotations.Nullable; + +/** + * A globally-deduplicated piece of content (function, class, markdown section, sliding-window + * fallback). Identified by {@link #contentHash()}: two chunks with the same hash are the same + * chunk, regardless of which repo/tag/file they originated from. + * + * @param symbol AST symbol name when applicable (e.g. function or class), null for prose chunks + */ +public record Chunk( + ChunkId id, + String contentHash, + String content, + String language, + @Nullable String symbol, + int tokenCount) {} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/ChunkId.java b/trueref-domain/src/main/java/com/trueref/domain/model/ChunkId.java new file mode 100644 index 0000000..37b5aad --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/ChunkId.java @@ -0,0 +1,16 @@ +package com.trueref.domain.model; + +public record ChunkId(java.util.UUID value) { + public static ChunkId random() { + return new ChunkId(java.util.UUID.randomUUID()); + } + + public static ChunkId of(String s) { + return new ChunkId(java.util.UUID.fromString(s)); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/ChunkVersion.java b/trueref-domain/src/main/java/com/trueref/domain/model/ChunkVersion.java new file mode 100644 index 0000000..a604ac5 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/ChunkVersion.java @@ -0,0 +1,8 @@ +package com.trueref.domain.model; + +/** + * Many-to-many edge between a {@link Chunk} and a {@link Version}. Carries the location of the + * chunk inside the version's source tree. + */ +public record ChunkVersion( + ChunkId chunkId, VersionId versionId, String filePath, int startLine, int endLine) {} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/Embedding.java b/trueref-domain/src/main/java/com/trueref/domain/model/Embedding.java new file mode 100644 index 0000000..4bf2830 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/Embedding.java @@ -0,0 +1,19 @@ +package com.trueref.domain.model; + +/** Vector representation of a {@link Chunk}. Dense float vector; sparse channel deferred. */ +public record Embedding(ChunkId chunkId, float[] vector) { + + public Embedding { + // Defensive copy to make the record effectively immutable. + vector = vector.clone(); + } + + @Override + public float[] vector() { + return vector.clone(); + } + + public int dimension() { + return vector.length; + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/IngestionJob.java b/trueref-domain/src/main/java/com/trueref/domain/model/IngestionJob.java new file mode 100644 index 0000000..acf96a5 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/IngestionJob.java @@ -0,0 +1,25 @@ +package com.trueref.domain.model; + +import java.time.Instant; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * A unit of orchestrated work. One job has many {@link JobStage stages} executed in sequence. + * + * @param versionId null for repo-level jobs (e.g. {@link JobType#DISCOVER_TAGS}) + */ +public record IngestionJob( + JobId id, + RepositoryId repoId, + @Nullable VersionId versionId, + JobType type, + JobStatus status, + @Nullable Instant startedAt, + @Nullable Instant finishedAt, + List stages) { + + public IngestionJob { + stages = List.copyOf(stages); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/JobId.java b/trueref-domain/src/main/java/com/trueref/domain/model/JobId.java new file mode 100644 index 0000000..c50819b --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/JobId.java @@ -0,0 +1,16 @@ +package com.trueref.domain.model; + +public record JobId(java.util.UUID value) { + public static JobId random() { + return new JobId(java.util.UUID.randomUUID()); + } + + public static JobId of(String s) { + return new JobId(java.util.UUID.fromString(s)); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/JobLogEvent.java b/trueref-domain/src/main/java/com/trueref/domain/model/JobLogEvent.java new file mode 100644 index 0000000..08be510 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/JobLogEvent.java @@ -0,0 +1,20 @@ +package com.trueref.domain.model; + +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +/** A single emitted observability event for an ingestion job. Streamed via SSE to the UI. */ +public record JobLogEvent( + JobId jobId, + Instant ts, + Level level, + JobStage.@Nullable StageName stage, + String message) { + + public enum Level { + DEBUG, + INFO, + WARN, + ERROR + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/JobStage.java b/trueref-domain/src/main/java/com/trueref/domain/model/JobStage.java new file mode 100644 index 0000000..dc09c3e --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/JobStage.java @@ -0,0 +1,37 @@ +package com.trueref.domain.model; + +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +public record JobStage( + JobId jobId, + StageName name, + StageStatus status, + @Nullable Instant startedAt, + @Nullable Instant finishedAt, + long itemsProcessed, + long itemsTotal, + long bytesProcessed, + @Nullable String errorMessage) { + + public enum StageName { + CLONE, + FETCH, + CHECKOUT, + DISCOVER_FILES, + DIFF_FILES, + PARSE, + CHUNK, + EMBED, + INDEX, + COMMIT + } + + public enum StageStatus { + PENDING, + RUNNING, + SUCCEEDED, + FAILED, + SKIPPED + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/JobStatus.java b/trueref-domain/src/main/java/com/trueref/domain/model/JobStatus.java new file mode 100644 index 0000000..0513937 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/JobStatus.java @@ -0,0 +1,9 @@ +package com.trueref.domain.model; + +public enum JobStatus { + QUEUED, + RUNNING, + SUCCEEDED, + FAILED, + CANCELLED +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/JobType.java b/trueref-domain/src/main/java/com/trueref/domain/model/JobType.java new file mode 100644 index 0000000..aa94fef --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/JobType.java @@ -0,0 +1,8 @@ +package com.trueref.domain.model; + +public enum JobType { + DISCOVER_TAGS, + INDEX_VERSION, + REFRESH, + COMPACT +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/Repository.java b/trueref-domain/src/main/java/com/trueref/domain/model/Repository.java new file mode 100644 index 0000000..cca320b --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/Repository.java @@ -0,0 +1,37 @@ +package com.trueref.domain.model; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * A registered git repository (local or remote-cloned). The {@code localPath} is always present; + * for remote repositories it points to our managed clone directory and {@code managedClone} is true. + * + * @param remoteUrl git URL when {@code managedClone} is true; null otherwise + * @param ignoreGlobs per-repo globs ANDed with .gitignore + built-in defaults + * @param maxFileSizeBytes files larger than this are skipped during ingestion + * @param pollInterval scheduled fetch interval; {@link Duration#ZERO} disables polling + * @param tagCap max most-recent tags to auto-index; UI/MCP can index more on demand + * @param versionMappingRules ordered patterns mapping a client version (e.g. {@code "1.2.3"}) to a tag + */ +public record Repository( + RepositoryId id, + String name, + @Nullable String remoteUrl, + String localPath, + boolean managedClone, + List ignoreGlobs, + long maxFileSizeBytes, + Duration pollInterval, + int tagCap, + List versionMappingRules, + Instant createdAt, + Instant updatedAt) { + + public Repository { + ignoreGlobs = List.copyOf(ignoreGlobs); + versionMappingRules = List.copyOf(versionMappingRules); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/RepositoryId.java b/trueref-domain/src/main/java/com/trueref/domain/model/RepositoryId.java new file mode 100644 index 0000000..0636426 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/RepositoryId.java @@ -0,0 +1,17 @@ +package com.trueref.domain.model; + +/** Type-safe identifier for a registered repository. */ +public record RepositoryId(java.util.UUID value) { + public static RepositoryId random() { + return new RepositoryId(java.util.UUID.randomUUID()); + } + + public static RepositoryId of(String s) { + return new RepositoryId(java.util.UUID.fromString(s)); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/SearchHit.java b/trueref-domain/src/main/java/com/trueref/domain/model/SearchHit.java new file mode 100644 index 0000000..fb70eba --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/SearchHit.java @@ -0,0 +1,18 @@ +package com.trueref.domain.model; + +import org.jspecify.annotations.Nullable; + +/** A single ranked snippet returned from a search. */ +public record SearchHit( + ChunkId chunkId, + RepositoryId repoId, + VersionId versionId, + String repoName, + String tag, + String filePath, + int startLine, + int endLine, + String language, + @Nullable String symbol, + String content, + double score) {} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/SearchScope.java b/trueref-domain/src/main/java/com/trueref/domain/model/SearchScope.java new file mode 100644 index 0000000..b8d428f --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/SearchScope.java @@ -0,0 +1,16 @@ +package com.trueref.domain.model; + +import java.util.List; + +/** + * Defines the (repo, version) scope of a search request. Multiple scopes can be ORed together so a + * single query may span "spring-boot v3.5.4" and "spring-boot v3.4.0", for example. + */ +public record SearchScope(List refs) { + + public SearchScope { + refs = List.copyOf(refs); + } + + public record RepoVersionRef(RepositoryId repoId, VersionId versionId) {} +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/TagPattern.java b/trueref-domain/src/main/java/com/trueref/domain/model/TagPattern.java new file mode 100644 index 0000000..10658ee --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/TagPattern.java @@ -0,0 +1,24 @@ +package com.trueref.domain.model; + +/** + * Strategy for mapping a client-supplied version string to a git tag in a repository. Patterns are + * tried in order; the first match wins. Built-in patterns: EXACT, V_PREFIX, RELEASE_PREFIX, + * SEMVER_FUZZY. CUSTOM allows a user-supplied template like {@code "release-{semver}"}. + */ +public sealed interface TagPattern { + + /** {@code "1.2.3"} → tag {@code "1.2.3"}. */ + record Exact() implements TagPattern {} + + /** {@code "1.2.3"} → tag {@code "v1.2.3"}. */ + record VPrefix() implements TagPattern {} + + /** {@code "1.2.3"} → tag {@code "release-1.2.3"}. */ + record ReleasePrefix() implements TagPattern {} + + /** Any tag whose semver is closest to the requested version. */ + record SemverFuzzy() implements TagPattern {} + + /** Custom template containing {@code {version}} or {@code {semver}} placeholders. */ + record Custom(String template) implements TagPattern {} +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/Version.java b/trueref-domain/src/main/java/com/trueref/domain/model/Version.java new file mode 100644 index 0000000..d92e4ff --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/Version.java @@ -0,0 +1,15 @@ +package com.trueref.domain.model; + +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +/** A specific git tag (or branch) of a {@link Repository} that may be indexed independently. */ +public record Version( + VersionId id, + RepositoryId repoId, + String tag, + String commitSha, + VersionStatus status, + @Nullable Instant indexedAt, + int chunkCount, + @Nullable String errorMessage) {} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/VersionId.java b/trueref-domain/src/main/java/com/trueref/domain/model/VersionId.java new file mode 100644 index 0000000..e3002d0 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/VersionId.java @@ -0,0 +1,16 @@ +package com.trueref.domain.model; + +public record VersionId(java.util.UUID value) { + public static VersionId random() { + return new VersionId(java.util.UUID.randomUUID()); + } + + public static VersionId of(String s) { + return new VersionId(java.util.UUID.fromString(s)); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/VersionStatus.java b/trueref-domain/src/main/java/com/trueref/domain/model/VersionStatus.java new file mode 100644 index 0000000..16caa98 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/VersionStatus.java @@ -0,0 +1,14 @@ +package com.trueref.domain.model; + +public enum VersionStatus { + /** Tag known but not yet indexed. */ + DISCOVERED, + /** Indexing job currently running. */ + INDEXING, + /** Successfully indexed and queryable. */ + INDEXED, + /** Last indexing attempt failed; see {@link Version#errorMessage()}. */ + FAILED, + /** Tag no longer exists upstream; chunks reclaimable by compaction. */ + INACTIVE +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/model/package-info.java b/trueref-domain/src/main/java/com/trueref/domain/model/package-info.java new file mode 100644 index 0000000..3aaae9e --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/model/package-info.java @@ -0,0 +1,7 @@ +/** + * Pure domain model for trueref. Contains records and enums describing repositories, versions, + * chunks, ingestion jobs, and search results. Must remain free of any I/O, Spring, + * Jackson, or other framework concerns. JSpecify nullability annotations are allowed. + */ +@org.jspecify.annotations.NullMarked +package com.trueref.domain.model; diff --git a/trueref-domain/src/main/java/com/trueref/domain/port/in/DiscoverVersions.java b/trueref-domain/src/main/java/com/trueref/domain/port/in/DiscoverVersions.java new file mode 100644 index 0000000..33962b1 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/port/in/DiscoverVersions.java @@ -0,0 +1,12 @@ +package com.trueref.domain.port.in; + +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.Version; +import java.util.List; + +/** Use case: discover/refresh git tags of a repository. */ +public interface DiscoverVersions { + + /** Performs git fetch (if managed) + tag enumeration. Returns the now-known versions. */ + List discover(RepositoryId repoId); +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/port/in/IndexVersion.java b/trueref-domain/src/main/java/com/trueref/domain/port/in/IndexVersion.java new file mode 100644 index 0000000..c953d9d --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/port/in/IndexVersion.java @@ -0,0 +1,12 @@ +package com.trueref.domain.port.in; + +import com.trueref.domain.model.JobId; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.VersionId; + +/** Use case: schedule indexing of a specific (repo, tag/version). */ +public interface IndexVersion { + + /** Enqueues an INDEX_VERSION job. Returns immediately with the job id. */ + JobId enqueue(RepositoryId repoId, VersionId versionId, boolean force); +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/port/in/ObserveJobs.java b/trueref-domain/src/main/java/com/trueref/domain/port/in/ObserveJobs.java new file mode 100644 index 0000000..2da14f8 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/port/in/ObserveJobs.java @@ -0,0 +1,27 @@ +package com.trueref.domain.port.in; + +import com.trueref.domain.model.IngestionJob; +import com.trueref.domain.model.JobId; +import com.trueref.domain.model.JobLogEvent; +import com.trueref.domain.model.JobStatus; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.VersionId; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + +/** Use case: read jobs and subscribe to job/log streams (for SSE in the UI). */ +public interface ObserveJobs { + + Optional findJob(JobId id); + + List listJobs( + @Nullable RepositoryId repoId, @Nullable VersionId versionId, @Nullable JobStatus status, int limit); + + /** Subscribes to live status updates of all jobs. Returns an unsubscribe handle. */ + AutoCloseable subscribeJobs(Consumer listener); + + /** Subscribes to log events of a single job. Returns an unsubscribe handle. */ + AutoCloseable subscribeLogs(JobId jobId, Consumer listener); +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/port/in/QueryCatalog.java b/trueref-domain/src/main/java/com/trueref/domain/port/in/QueryCatalog.java new file mode 100644 index 0000000..4b287ef --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/port/in/QueryCatalog.java @@ -0,0 +1,17 @@ +package com.trueref.domain.port.in; + +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.Version; +import java.util.List; +import java.util.Optional; + +/** Use case: read-only access to repositories and their versions. */ +public interface QueryCatalog { + + List listRepositories(); + + Optional findRepository(RepositoryId id); + + List listVersions(RepositoryId repoId); +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/port/in/RegisterRepository.java b/trueref-domain/src/main/java/com/trueref/domain/port/in/RegisterRepository.java new file mode 100644 index 0000000..d6daf0a --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/port/in/RegisterRepository.java @@ -0,0 +1,32 @@ +package com.trueref.domain.port.in; + +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.TagPattern; +import java.time.Duration; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** Use case: register a new repository (local path or remote URL). */ +public interface RegisterRepository { + + Repository register(Command cmd); + + record Command( + String name, + @Nullable String remoteUrl, + @Nullable String localPath, + List ignoreGlobs, + @Nullable Long maxFileSizeBytes, + @Nullable Duration pollInterval, + @Nullable Integer tagCap, + List versionMappingRules) { + + public Command { + ignoreGlobs = List.copyOf(ignoreGlobs); + versionMappingRules = List.copyOf(versionMappingRules); + } + } + + void unregister(RepositoryId id); +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/port/in/ResolveLibraryId.java b/trueref-domain/src/main/java/com/trueref/domain/port/in/ResolveLibraryId.java new file mode 100644 index 0000000..edfbd13 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/port/in/ResolveLibraryId.java @@ -0,0 +1,40 @@ +package com.trueref.domain.port.in; + +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.VersionId; +import com.trueref.domain.model.VersionStatus; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Use case: turn a fuzzy library name (and optional version) into one or more concrete (repo, + * version) handles, ranked by relevance. Mirrors Context7's {@code resolve-library-id}. + */ +public interface ResolveLibraryId { + + Result resolve(Query query); + + record Query(String libraryName, @Nullable String query, @Nullable String version) {} + + record Result(List matches) { + public Result { + matches = List.copyOf(matches); + } + } + + record Match( + RepositoryId repoId, + String libraryId, // "/owner/repo[/version]" + String name, + @Nullable String description, + int snippetCount, + List availableVersions, + double score) { + + public Match { + availableVersions = List.copyOf(availableVersions); + } + } + + record VersionRef(VersionId versionId, String tag, VersionStatus status) {} +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/port/in/SearchLibraryDocs.java b/trueref-domain/src/main/java/com/trueref/domain/port/in/SearchLibraryDocs.java new file mode 100644 index 0000000..14ff431 --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/port/in/SearchLibraryDocs.java @@ -0,0 +1,30 @@ +package com.trueref.domain.port.in; + +import com.trueref.domain.model.SearchHit; +import com.trueref.domain.model.SearchScope; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** Use case: hybrid (BM25 + dense) search with rerank, scoped to specific (repo, version) pairs. */ +public interface SearchLibraryDocs { + + Result search(Query query); + + record Query( + String text, + @Nullable String topic, + SearchScope scope, + int tokensBudget, + int maxHits) {} + + /** + * @param hits ranked snippets, packed to fit within {@link Query#tokensBudget()} + * @param totalTokensReturned cumulative token count of returned snippets + */ + record Result(List hits, int totalTokensReturned) { + + public Result { + hits = List.copyOf(hits); + } + } +} diff --git a/trueref-domain/src/main/java/com/trueref/domain/port/in/package-info.java b/trueref-domain/src/main/java/com/trueref/domain/port/in/package-info.java new file mode 100644 index 0000000..2bcf61c --- /dev/null +++ b/trueref-domain/src/main/java/com/trueref/domain/port/in/package-info.java @@ -0,0 +1,6 @@ +/** + * Driving ports — interfaces implemented by the application layer and called by adapters + * (REST controllers, MCP tool handlers, scheduled tasks, etc.). + */ +@org.jspecify.annotations.NullMarked +package com.trueref.domain.port.in; diff --git a/trueref-frontend/pom.xml b/trueref-frontend/pom.xml new file mode 100644 index 0000000..5bd7096 --- /dev/null +++ b/trueref-frontend/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + + com.trueref + trueref-parent + 0.1.0-SNAPSHOT + + + trueref-frontend + trueref-frontend + SvelteKit static UI built with frontend-maven-plugin and packaged as a resource jar. + jar + + + + + + web/build + static + + + + + com.github.eirslett + frontend-maven-plugin + + web + ${project.build.directory} + ${node.version} + ${npm.version} + + + + install-node-and-npm + install-node-and-npm + generate-resources + + + npm-install + npm + generate-resources + install + + + npm-build + npm + generate-resources + run build + + + + + + diff --git a/trueref-frontend/web/.gitignore b/trueref-frontend/web/.gitignore new file mode 100644 index 0000000..5a4e8e4 --- /dev/null +++ b/trueref-frontend/web/.gitignore @@ -0,0 +1,9 @@ +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +.DS_Store +*.log diff --git a/trueref-frontend/web/.npmrc b/trueref-frontend/web/.npmrc new file mode 100644 index 0000000..c0c80ba --- /dev/null +++ b/trueref-frontend/web/.npmrc @@ -0,0 +1 @@ +engine-strict=false diff --git a/trueref-frontend/web/.prettierrc b/trueref-frontend/web/.prettierrc new file mode 100644 index 0000000..80e397b --- /dev/null +++ b/trueref-frontend/web/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": false, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/trueref-frontend/web/package-lock.json b/trueref-frontend/web/package-lock.json new file mode 100644 index 0000000..ae68058 --- /dev/null +++ b/trueref-frontend/web/package-lock.json @@ -0,0 +1,2202 @@ +{ + "name": "trueref-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trueref-web", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.22.0", + "@sveltejs/vite-plugin-svelte": "^5.1.0", + "shiki": "^3.2.0", + "svelte": "^5.39.0", + "svelte-check": "^4.2.0", + "typescript": "^5.9.0", + "vite": "^6.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.57.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz", + "integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devalue": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", + "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", + "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/svelte": { + "version": "5.55.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", + "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/trueref-frontend/web/package.json b/trueref-frontend/web/package.json new file mode 100644 index 0000000..96c5345 --- /dev/null +++ b/trueref-frontend/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "trueref-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.22.0", + "@sveltejs/vite-plugin-svelte": "^5.1.0", + "svelte": "^5.39.0", + "svelte-check": "^4.2.0", + "shiki": "^3.2.0", + "typescript": "^5.9.0", + "vite": "^6.3.0" + } +} diff --git a/trueref-frontend/web/src/app.css b/trueref-frontend/web/src/app.css new file mode 100644 index 0000000..f61f12d --- /dev/null +++ b/trueref-frontend/web/src/app.css @@ -0,0 +1,190 @@ +:root { + --bg: #0b0d12; + --bg-alt: #0f131a; + --bg-card: #141925; + --bg-card-hover: #1a2030; + --border: #1f2533; + --fg: #e6e8ee; + --fg-dim: #b2b7c3; + --muted: #6b7280; + --accent: #7aa2f7; + --accent-dim: #3b4a6b; + --ok: #9ece6a; + --warn: #e0af68; + --err: #f7768e; + --mono: 'JetBrains Mono', 'Fira Code', 'Menlo', 'Consolas', monospace; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Arial, + sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +h1, +h2, +h3 { + margin: 0; + font-weight: 600; +} +h1 { + font-size: 22px; +} +h2 { + font-size: 17px; +} +h3 { + font-size: 14px; + color: var(--fg-dim); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +button { + background: var(--bg-card); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} +button:hover { + background: var(--bg-card-hover); + border-color: var(--accent-dim); +} +button.primary { + background: var(--accent); + color: #0b0d12; + border-color: var(--accent); + font-weight: 600; +} +button.primary:hover { + filter: brightness(1.05); +} +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +input, +select, +textarea { + background: var(--bg-alt); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px; + font-size: 13px; + font-family: inherit; +} +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); +} + +label { + font-size: 12px; + color: var(--fg-dim); + display: flex; + flex-direction: column; + gap: 4px; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +th, +td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--border); +} +th { + color: var(--fg-dim); + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; +} + +.grid { + display: grid; + gap: 16px; +} + +.page-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.muted { + color: var(--muted); +} +.mono { + font-family: var(--mono); +} +.empty { + color: var(--muted); + font-style: italic; + padding: 12px 0; +} + +/* dialog / modal */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: grid; + place-items: center; + z-index: 900; +} +.modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 20px; + min-width: 420px; + max-width: 560px; +} diff --git a/trueref-frontend/web/src/app.d.ts b/trueref-frontend/web/src/app.d.ts new file mode 100644 index 0000000..444b555 --- /dev/null +++ b/trueref-frontend/web/src/app.d.ts @@ -0,0 +1,12 @@ +// See https://svelte.dev/docs/kit/types#app +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/trueref-frontend/web/src/app.html b/trueref-frontend/web/src/app.html new file mode 100644 index 0000000..c836763 --- /dev/null +++ b/trueref-frontend/web/src/app.html @@ -0,0 +1,13 @@ + + + + + + + trueref + %sveltekit.head% + + +

%sveltekit.body%
+ + diff --git a/trueref-frontend/web/src/lib/api.ts b/trueref-frontend/web/src/lib/api.ts new file mode 100644 index 0000000..9798a1e --- /dev/null +++ b/trueref-frontend/web/src/lib/api.ts @@ -0,0 +1,119 @@ +import { pushToast } from './toast'; +import type { + CreateRepositoryRequest, + JobDto, + MetricsDto, + RepositoryDto, + ResolveMatchDto, + ResolveRequest, + ResourcesDto, + SearchRequest, + SearchResponseDto, + VersionDto +} from './types'; + +export class ApiError extends Error { + status: number; + body: unknown; + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +async function request(path: string, init?: RequestInit): Promise { + try { + const res = await fetch(path, { + ...init, + headers: { + Accept: 'application/json', + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.headers ?? {}) + } + }); + if (!res.ok) { + let body: unknown = null; + try { + body = await res.json(); + } catch { + try { + body = await res.text(); + } catch { + /* empty */ + } + } + const msg = `${init?.method ?? 'GET'} ${path} → ${res.status}`; + pushToast({ level: 'error', message: msg }); + throw new ApiError(res.status, msg, body); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } catch (err) { + if (err instanceof ApiError) throw err; + const msg = err instanceof Error ? err.message : String(err); + pushToast({ level: 'error', message: `Network error: ${msg}` }); + throw err; + } +} + +function qs(params: Record): string { + const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== ''); + if (entries.length === 0) return ''; + const sp = new URLSearchParams(); + for (const [k, v] of entries) sp.append(k, String(v)); + return `?${sp.toString()}`; +} + +// ---------- Repos ---------- + +export const listRepos = (): Promise => request('/api/repos'); + +export const getRepo = (id: string): Promise => request(`/api/repos/${id}`); + +export const createRepo = (body: CreateRepositoryRequest): Promise => + request('/api/repos', { method: 'POST', body: JSON.stringify(body) }); + +export const deleteRepo = (id: string): Promise => + request(`/api/repos/${id}`, { method: 'DELETE' }); + +export const discoverTags = (id: string): Promise => + request(`/api/repos/${id}/discover`, { method: 'POST' }); + +export const listVersions = (id: string): Promise => + request(`/api/repos/${id}/versions`); + +export const indexVersion = (id: string, tag: string): Promise => + request(`/api/repos/${id}/versions/${encodeURIComponent(tag)}/index`, { method: 'POST' }); + +export const reindexVersion = (id: string, tag: string): Promise => + request(`/api/repos/${id}/versions/${encodeURIComponent(tag)}/reindex`, { method: 'POST' }); + +// ---------- Jobs ---------- + +export interface JobFilter { + repoId?: string; + versionId?: string; + status?: string; + limit?: number; + offset?: number; +} + +export const listJobs = (f: JobFilter = {}): Promise => + request(`/api/jobs${qs(f as Record)}`); + +export const getJob = (id: string): Promise => request(`/api/jobs/${id}`); + +// ---------- Search / Resolve ---------- + +export const search = (body: SearchRequest): Promise => + request('/api/search', { method: 'POST', body: JSON.stringify(body) }); + +export const resolveLibrary = (body: ResolveRequest): Promise => + request(`/api/resolve${qs({ q: body.libraryName })}`); + +// ---------- Observability ---------- + +export const getResources = (): Promise => request('/api/observability/resources'); + +export const getMetrics = (): Promise => request('/api/observability/metrics'); diff --git a/trueref-frontend/web/src/lib/components/BarChart.svelte b/trueref-frontend/web/src/lib/components/BarChart.svelte new file mode 100644 index 0000000..0f5527a --- /dev/null +++ b/trueref-frontend/web/src/lib/components/BarChart.svelte @@ -0,0 +1,73 @@ + + +
+ {#each safeData as d (d.label)} +
+
{d.label}
+
+
+
+
{format(d.value)}
+
+ {:else} +
No data yet.
+ {/each} +
+ + diff --git a/trueref-frontend/web/src/lib/components/CodeBlock.svelte b/trueref-frontend/web/src/lib/components/CodeBlock.svelte new file mode 100644 index 0000000..e02e780 --- /dev/null +++ b/trueref-frontend/web/src/lib/components/CodeBlock.svelte @@ -0,0 +1,108 @@ + + +
+ {#if html} + + {@html html} + {:else} +
{code}
+ {/if} +
+ + diff --git a/trueref-frontend/web/src/lib/components/JobRow.svelte b/trueref-frontend/web/src/lib/components/JobRow.svelte new file mode 100644 index 0000000..2a9e5c5 --- /dev/null +++ b/trueref-frontend/web/src/lib/components/JobRow.svelte @@ -0,0 +1,69 @@ + + + + + diff --git a/trueref-frontend/web/src/lib/components/LogTail.svelte b/trueref-frontend/web/src/lib/components/LogTail.svelte new file mode 100644 index 0000000..0b47269 --- /dev/null +++ b/trueref-frontend/web/src/lib/components/LogTail.svelte @@ -0,0 +1,169 @@ + + +
+
+ + {#if $log.connected} + ● live + {:else if done} + {jobStatus === 'SUCCEEDED' ? '● finished' : '✗ failed'} + {:else} + ○ connecting… + {/if} + + {#if $log.error}{$log.error}{/if} + +
+
+ {#each $log.items as ev (ev.ts + ev.message)} +
+ {new Date(ev.ts).toLocaleTimeString()} + {ev.level} + {#if ev.stage}{ev.stage}{/if} + {ev.message} +
+ {:else} +
Waiting for log events…
+ {/each} +
+
+ + diff --git a/trueref-frontend/web/src/lib/components/RepoCard.svelte b/trueref-frontend/web/src/lib/components/RepoCard.svelte new file mode 100644 index 0000000..03eeaed --- /dev/null +++ b/trueref-frontend/web/src/lib/components/RepoCard.svelte @@ -0,0 +1,85 @@ + + +
+
+
+ +
+
+ {repo.managedClone ? 'managed' : 'local'} · updated {formatRelative(repo.updatedAt)} +
+
+
+
versions{repo.versionCount ?? '—'}
+
indexed{repo.indexedVersionCount ?? '—'}
+
chunks{repo.chunkCount?.toLocaleString() ?? '—'}
+
+
+ + +
+
+ + diff --git a/trueref-frontend/web/src/lib/components/Sparkline.svelte b/trueref-frontend/web/src/lib/components/Sparkline.svelte new file mode 100644 index 0000000..423d908 --- /dev/null +++ b/trueref-frontend/web/src/lib/components/Sparkline.svelte @@ -0,0 +1,54 @@ + + +
+ {#if label}
{label}
{/if} + +
+ + diff --git a/trueref-frontend/web/src/lib/components/StageProgress.svelte b/trueref-frontend/web/src/lib/components/StageProgress.svelte new file mode 100644 index 0000000..fdedea9 --- /dev/null +++ b/trueref-frontend/web/src/lib/components/StageProgress.svelte @@ -0,0 +1,71 @@ + + +
+
{stage.name}
+
+
+
+
+ {stage.itemsProcessed.toLocaleString()} / {stage.itemsTotal.toLocaleString()} +
+
+
+ + diff --git a/trueref-frontend/web/src/lib/components/ToastContainer.svelte b/trueref-frontend/web/src/lib/components/ToastContainer.svelte new file mode 100644 index 0000000..7c01b2d --- /dev/null +++ b/trueref-frontend/web/src/lib/components/ToastContainer.svelte @@ -0,0 +1,67 @@ + + +
+ {#each $toasts as t (t.id)} +
+ {t.message} + +
+ {/each} +
+ + diff --git a/trueref-frontend/web/src/lib/components/VersionBadge.svelte b/trueref-frontend/web/src/lib/components/VersionBadge.svelte new file mode 100644 index 0000000..eb50f63 --- /dev/null +++ b/trueref-frontend/web/src/lib/components/VersionBadge.svelte @@ -0,0 +1,50 @@ + + +{label ?? status} + + diff --git a/trueref-frontend/web/src/lib/format.ts b/trueref-frontend/web/src/lib/format.ts new file mode 100644 index 0000000..768bda0 --- /dev/null +++ b/trueref-frontend/web/src/lib/format.ts @@ -0,0 +1,45 @@ +export function formatBytes(bytes: number | null | undefined): string { + if (bytes == null || !Number.isFinite(bytes)) return '—'; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + let v = bytes; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`; +} + +export function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return '—'; + if (ms < 1000) return `${ms.toFixed(0)} ms`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(1)}s`; + const m = Math.floor(s / 60); + const rem = Math.floor(s % 60); + return `${m}m ${rem}s`; +} + +export function formatRelative(iso: string | null | undefined): string { + if (!iso) return '—'; + const t = new Date(iso).getTime(); + if (!Number.isFinite(t)) return iso ?? '—'; + const diff = Date.now() - t; + const s = Math.max(0, Math.floor(diff / 1000)); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return `${d}d ago`; +} + +export function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +export function percent(part: number, total: number): number { + if (!total || total <= 0) return 0; + return clamp((part / total) * 100, 0, 100); +} diff --git a/trueref-frontend/web/src/lib/sse.ts b/trueref-frontend/web/src/lib/sse.ts new file mode 100644 index 0000000..32dfbb0 --- /dev/null +++ b/trueref-frontend/web/src/lib/sse.ts @@ -0,0 +1,186 @@ +import { readable, type Readable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface SseOptions { + parse?: (raw: string) => T; + event?: string; // named SSE event (default: 'message') + initial?: T | null; + reconnectMs?: number; +} + +export interface SseState { + value: T | null; + connected: boolean; + error: string | null; +} + +/** + * Returns a Svelte store connected to an EventSource with auto-reconnect. + * The store yields the latest parsed value, connection status, and last error. + */ +export function sseStore(url: string, opts: SseOptions = {}): Readable> { + const parse = opts.parse ?? ((raw: string) => JSON.parse(raw) as T); + const eventName = opts.event ?? 'message'; + const reconnectMs = opts.reconnectMs ?? 2000; + + return readable>( + { value: opts.initial ?? null, connected: false, error: null }, + (set) => { + if (!browser) return () => {}; + + let es: EventSource | null = null; + let timer: ReturnType | null = null; + let closed = false; + let state: SseState = { value: opts.initial ?? null, connected: false, error: null }; + + const push = (patch: Partial>) => { + state = { ...state, ...patch }; + set(state); + }; + + const connect = () => { + if (closed) return; + try { + es = new EventSource(url); + } catch (err) { + push({ error: err instanceof Error ? err.message : String(err), connected: false }); + schedule(); + return; + } + es.addEventListener('open', () => push({ connected: true, error: null })); + es.addEventListener(eventName, (ev) => { + const me = ev as MessageEvent; + try { + push({ value: parse(me.data) }); + } catch (err) { + push({ error: err instanceof Error ? err.message : String(err) }); + } + }); + es.addEventListener('error', () => { + push({ connected: false }); + try { + es?.close(); + } catch { + /* noop */ + } + es = null; + schedule(); + }); + }; + + const schedule = () => { + if (closed || timer) return; + timer = setTimeout(() => { + timer = null; + connect(); + }, reconnectMs); + }; + + connect(); + + return () => { + closed = true; + if (timer) clearTimeout(timer); + timer = null; + if (es) { + try { + es.close(); + } catch { + /* noop */ + } + } + }; + } + ); +} + +/** + * Append-only SSE store — accumulates events into a ring buffer. Useful for logs. + */ +export function sseAppendStore( + url: string, + opts: SseOptions & { capacity?: number } = {} +): Readable<{ items: T[]; connected: boolean; error: string | null }> { + const parse = opts.parse ?? ((raw: string) => JSON.parse(raw) as T); + const eventName = opts.event ?? 'message'; + const reconnectMs = opts.reconnectMs ?? 2000; + const capacity = opts.capacity ?? 2000; + + return readable<{ items: T[]; connected: boolean; error: string | null }>( + { items: [], connected: false, error: null }, + (set) => { + if (!browser) return () => {}; + let es: EventSource | null = null; + let timer: ReturnType | null = null; + let closed = false; + let items: T[] = []; + let connected = false; + let error: string | null = null; + + const push = () => set({ items: items.slice(), connected, error }); + + const connect = () => { + if (closed) return; + try { + es = new EventSource(url); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + connected = false; + push(); + schedule(); + return; + } + es.addEventListener('open', () => { + connected = true; + error = null; + push(); + }); + es.addEventListener(eventName, (ev) => { + const me = ev as MessageEvent; + try { + items.push(parse(me.data)); + if (items.length > capacity) items.splice(0, items.length - capacity); + push(); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + push(); + } + }); + es.addEventListener('error', () => { + connected = false; + push(); + try { + es?.close(); + } catch { + /* noop */ + } + es = null; + schedule(); + }); + }; + + const schedule = () => { + if (closed || timer) return; + timer = setTimeout(() => { + timer = null; + connect(); + }, reconnectMs); + }; + + connect(); + + return () => { + closed = true; + if (timer) clearTimeout(timer); + timer = null; + if (es) { + try { + es.close(); + } catch { + /* noop */ + } + } + }; + } + ); +} diff --git a/trueref-frontend/web/src/lib/toast.ts b/trueref-frontend/web/src/lib/toast.ts new file mode 100644 index 0000000..7bf2ce7 --- /dev/null +++ b/trueref-frontend/web/src/lib/toast.ts @@ -0,0 +1,27 @@ +import { writable } from 'svelte/store'; + +export type ToastLevel = 'info' | 'success' | 'warn' | 'error'; + +export interface Toast { + id: number; + level: ToastLevel; + message: string; + ttl: number; +} + +let nextId = 1; + +export const toasts = writable([]); + +export function pushToast(t: { level: ToastLevel; message: string; ttl?: number }): void { + const id = nextId++; + const ttl = t.ttl ?? 5000; + toasts.update((list) => [...list, { id, level: t.level, message: t.message, ttl }]); + if (typeof window !== 'undefined') { + setTimeout(() => dismissToast(id), ttl); + } +} + +export function dismissToast(id: number): void { + toasts.update((list) => list.filter((t) => t.id !== id)); +} diff --git a/trueref-frontend/web/src/lib/types.ts b/trueref-frontend/web/src/lib/types.ts new file mode 100644 index 0000000..ada424e --- /dev/null +++ b/trueref-frontend/web/src/lib/types.ts @@ -0,0 +1,173 @@ +// Mirrors the Java DTOs described in ARCHITECTURE §4 & §8. +// Shapes are the agreed contract between frontend and the REST adapter; +// any backend divergence is noted in FRONTEND_NOTES. + +export type VersionStatus = 'DISCOVERED' | 'INDEXING' | 'INDEXED' | 'FAILED' | 'INACTIVE'; + +export type JobType = 'DISCOVER_TAGS' | 'INDEX_VERSION' | 'COMPACT' | 'REFRESH'; + +export type JobStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED'; + +export type JobStageName = + | 'CLONE' + | 'FETCH' + | 'CHECKOUT' + | 'DISCOVER_FILES' + | 'PARSE' + | 'CHUNK' + | 'EMBED' + | 'INDEX' + | 'COMMIT'; + +export type JobStageStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'SKIPPED'; + +export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; + +export interface RepositoryDto { + id: string; + name: string; + remoteUrl: string | null; + localPath: string; + managedClone: boolean; + ignoreGlobs: string[]; + maxFileSizeBytes: number; + pollIntervalSec: number; + versionMappingRules: string[]; + createdAt: string; + updatedAt: string; + // Optional summary fields the backend may inline for list responses. + versionCount?: number; + indexedVersionCount?: number; + chunkCount?: number; +} + +export interface CreateRepositoryRequest { + name: string; + remoteUrl?: string | null; + localPath?: string | null; + managedClone?: boolean; + ignoreGlobs?: string[]; + maxFileSizeBytes?: number; + pollIntervalSec?: number; + versionMappingRules?: string[]; +} + +export interface VersionDto { + id: string; + repoId: string; + tag: string; + commitSha: string; + status: VersionStatus; + indexedAt: string | null; + chunkCount: number; + errorMessage: string | null; +} + +export interface JobStageDto { + name: JobStageName; + status: JobStageStatus; + startedAt: string | null; + finishedAt: string | null; + itemsProcessed: number; + itemsTotal: number; + bytesProcessed: number; + errorMessage: string | null; +} + +export interface JobDto { + id: string; + repoId: string; + repoName?: string; + versionId: string | null; + versionTag?: string | null; + type: JobType; + status: JobStatus; + startedAt: string | null; + finishedAt: string | null; + stages: JobStageDto[]; +} + +export interface JobLogEventDto { + jobId: string; + ts: string; + level: LogLevel; + stage: JobStageName | null; + message: string; +} + +export interface SearchScope { + repoId: string; + versionId?: string | null; + tag?: string | null; +} + +export interface SearchRequest { + text: string; + topic?: string | null; + scope: { repoId: string; versionId: string }[]; + tokensBudget?: number; + maxHits?: number; +} + +export interface SearchHitDto { + chunkId: string; + score: number; + repoId: string; + repoName: string; + versionId: string; + tag: string; + filePath: string; + startLine: number; + endLine: number; + language: string; + symbol: string | null; + content: string; +} + +export interface SearchResponseDto { + hits: SearchHitDto[]; + totalTokens: number; + tookMs: number; +} + +export interface ResolveRequest { + libraryName: string; + query?: string | null; +} + +export interface ResolveMatchDto { + libraryId: string; + name: string; + description: string | null; + snippetCount: number; + versions: string[]; + score: number; +} + +export interface ResourcesDto { + /** Nested heap object from /api/observability/resources */ + heap: { usedBytes: number; maxBytes: number; totalBytes: number }; + luceneIndexBytes: number; + embeddingCacheBytes: number; + trueRefHome: string; + /** null when nvidia-smi is absent or the device is not found */ + gpu: { deviceId: number; usedBytes: number; freeBytes: number; totalBytes: number } | null; +} + +export interface MetricsDto { + totalChunks: number; + totalRepositories: number; + totalVersionsIndexed: number; + jobsByStatus: Record; + jobsSampled: number; + jobsSampleLimit: number; + embedderAvailable: boolean; + rerankerAvailable: boolean; +} + +export interface Page { + items: T[]; + total: number; + limit: number; + offset: number; +} diff --git a/trueref-frontend/web/src/routes/+layout.svelte b/trueref-frontend/web/src/routes/+layout.svelte new file mode 100644 index 0000000..1d8c421 --- /dev/null +++ b/trueref-frontend/web/src/routes/+layout.svelte @@ -0,0 +1,187 @@ + + +
+ +
+
+
trueref
+
+
+ 0}> + + {runningCount === null ? '…' : runningCount} running + +
+
+
+ {@render children()} +
+
+ +
+ + diff --git a/trueref-frontend/web/src/routes/+layout.ts b/trueref-frontend/web/src/routes/+layout.ts new file mode 100644 index 0000000..485a369 --- /dev/null +++ b/trueref-frontend/web/src/routes/+layout.ts @@ -0,0 +1,4 @@ +// Static SPA; disable SSR so SvelteKit emits a single index.html fallback. +export const prerender = false; +export const ssr = false; +export const trailingSlash = 'never'; diff --git a/trueref-frontend/web/src/routes/+page.svelte b/trueref-frontend/web/src/routes/+page.svelte new file mode 100644 index 0000000..365f8dc --- /dev/null +++ b/trueref-frontend/web/src/routes/+page.svelte @@ -0,0 +1,218 @@ + + + + +
+
+
+

Jobs running now

+ {$liveJobs.connected ? 'live' : ($liveJobs.error ? 'reconnecting…' : 'connecting…')} +
+ {#if ($liveJobs.value?.length ?? 0) === 0} +
No jobs running.
+ {:else} +
+ {#each $liveJobs.value! as j (j.id)} +
+
+
+ {j.repoName ?? j.repoId.slice(0, 8)} + {#if j.versionTag}@ {j.versionTag}{/if} +
+ open → +
+
+ {#each j.stages ?? [] as s (s.name)} + + {/each} +
+
+ {/each} +
+ {/if} +
+ +
+
+

Resources

+
+ {#if !resources} +
Loading…
+ {:else} +
+
+

Heap

+
{formatBytes(resources.heap.usedBytes)}
+
+ of {formatBytes(resources.heap.maxBytes)} ({percent(resources.heap.usedBytes, resources.heap.maxBytes).toFixed(0)}%) +
+
+
+

Index size

+
{formatBytes(resources.luceneIndexBytes)}
+
embedding cache {formatBytes(resources.embeddingCacheBytes)}
+
+
+

Jobs

+
{metrics?.jobsByStatus?.['RUNNING'] ?? 0} running
+
{metrics?.jobsByStatus?.['FAILED'] ?? 0} failed · {metrics?.jobsByStatus?.['QUEUED'] ?? 0} queued
+
+
+

Indexed

+
{metrics?.totalChunks?.toLocaleString() ?? '—'} chunks
+
{metrics?.totalVersionsIndexed ?? 0} versions · {metrics?.totalRepositories ?? 0} repos
+
+
+ {/if} +
+ +
+
+

Chunks indexed

+ {totalChunks.toLocaleString()} +
+ +
+ +
+
+

Last 20 jobs

+ all jobs → +
+ {#if recentJobs.length === 0} +
No jobs yet.
+ {:else} +
+ {#each recentJobs as j (j.id)} + goto(`/jobs/${jj.id}`)} /> + {/each} +
+ {/if} +
+
+ + diff --git a/trueref-frontend/web/src/routes/jobs/+page.svelte b/trueref-frontend/web/src/routes/jobs/+page.svelte new file mode 100644 index 0000000..63da449 --- /dev/null +++ b/trueref-frontend/web/src/routes/jobs/+page.svelte @@ -0,0 +1,128 @@ + + + + +
+ + +
+ +{#if loading && jobs.length === 0} +
Loading…
+{:else if jobs.length === 0} +
No jobs match.
+{:else} +
+ {#each jobs as j (j.id)} + goto(`/jobs/${jj.id}`)} /> + {/each} +
+
+ + + {offset + 1}–{Math.min(offset + jobs.length, total)} of {total} + + +
+{/if} + + diff --git a/trueref-frontend/web/src/routes/jobs/[id]/+page.svelte b/trueref-frontend/web/src/routes/jobs/[id]/+page.svelte new file mode 100644 index 0000000..94ac4a0 --- /dev/null +++ b/trueref-frontend/web/src/routes/jobs/[id]/+page.svelte @@ -0,0 +1,91 @@ + + + + +{#if loading && !job} +
Loading…
+{:else if job} +
+

Type

{job.type}
+ +

Version

{job.versionTag ?? '—'}
+

Started

{formatRelative(job.startedAt)}
+

Finished

{formatRelative(job.finishedAt)}
+
+ +
+

Stages

+
+ {#each job.stages as s (s.name)} + + {/each} +
+
+ +
+

Log

+ +
+{/if} + + diff --git a/trueref-frontend/web/src/routes/repositories/+page.svelte b/trueref-frontend/web/src/routes/repositories/+page.svelte new file mode 100644 index 0000000..7f7b3ac --- /dev/null +++ b/trueref-frontend/web/src/routes/repositories/+page.svelte @@ -0,0 +1,160 @@ + + + + +{#if loading} +
Loading…
+{:else if repos.length === 0} +
No repositories yet. Click "Add repository" to register one.
+{:else} +
+ {#each repos as r (r.id)} + goto(`/repositories/${rr.id}`)} ondiscover={onDiscover} /> + {/each} +
+{/if} + +{#if showDialog} + +{/if} + + diff --git a/trueref-frontend/web/src/routes/repositories/[id]/+page.svelte b/trueref-frontend/web/src/routes/repositories/[id]/+page.svelte new file mode 100644 index 0000000..3742f86 --- /dev/null +++ b/trueref-frontend/web/src/routes/repositories/[id]/+page.svelte @@ -0,0 +1,232 @@ + + + + +{#if loading} +
Loading…
+{:else if repo} +
+
+

Remote

+
{repo.remoteUrl ?? '—'}
+
+
+

Local path

+
{repo.localPath}
+
+
+

Managed clone

+
{repo.managedClone ? 'yes' : 'no'}
+
+
+

Poll interval

+
{repo.pollIntervalSec}s
+
+
+

Max file size

+
{repo.maxFileSizeBytes.toLocaleString()} B
+
+
+

Updated

+
{formatRelative(repo.updatedAt)}
+
+
+ +
+
+

Index specific tag

+
+
+ + +
+

User-supplied tags are accepted; if the ref exists in the repo it will be checked out and indexed.

+
+ +
+
+

Versions ({versions.length})

+
+ {#if versions.some((v) => v.status === 'FAILED')} + + {/if} + +
+
+ {#if filtered.length === 0} +
No versions match.
+ {:else} + + + + + + + + + + + + + {#each filtered as v (v.id)} + + + + + + + + + {/each} + +
TagCommitStatusChunksIndexed
{v.tag}{v.commitSha.slice(0, 10)}{v.chunkCount.toLocaleString()}{formatRelative(v.indexedAt)} + {#if v.status === 'INDEXED'} + + {:else} + + {/if} +
+ {/if} +
+{/if} + + diff --git a/trueref-frontend/web/src/routes/resources/+page.svelte b/trueref-frontend/web/src/routes/resources/+page.svelte new file mode 100644 index 0000000..137f528 --- /dev/null +++ b/trueref-frontend/web/src/routes/resources/+page.svelte @@ -0,0 +1,103 @@ + + + + +
+
+

Heap

+
{formatBytes(current?.heap.usedBytes)}
+
+ of {formatBytes(current?.heap.maxBytes)} + ({current ? percent(current.heap.usedBytes, current.heap.maxBytes).toFixed(0) : '—'}%) +
+ +
+ +
+

GPU memory device {current?.gpu?.deviceId ?? '—'}

+ {#if current?.gpu} +
{formatBytes(current.gpu.usedBytes)}
+
+ of {formatBytes(current.gpu.totalBytes)} + ({percent(current.gpu.usedBytes, current.gpu.totalBytes).toFixed(0)}%) + · {formatBytes(current.gpu.freeBytes)} free +
+ {:else} +
+
nvidia-smi unavailable
+ {/if} + +
+ +
+

Lucene index

+
{formatBytes(current?.luceneIndexBytes)}
+
embedding cache {formatBytes(current?.embeddingCacheBytes)}
+ +
+ +
+

Home

+
{current?.trueRefHome ?? '—'}
+
+
+ + diff --git a/trueref-frontend/web/src/routes/search/+page.svelte b/trueref-frontend/web/src/routes/search/+page.svelte new file mode 100644 index 0000000..704d3f1 --- /dev/null +++ b/trueref-frontend/web/src/routes/search/+page.svelte @@ -0,0 +1,246 @@ + + + + +
+ + + + +
+ Scope + {#if repos.length === 0} +
No repositories. Register one first.
+ {:else} + {#each repos as r (r.id)} + {@const sel = selectedRepoIds.includes(r.id)} +
+ + {#if sel && versionsByRepo[r.id]} + + {/if} +
+ {/each} + {/if} +
+ +
+ +
+
+ +{#if hits.length > 0} +
+ {#each hits as h, i (h.chunkId + i)} +
+
+
+ {h.repoName} + @ {h.tag} + {h.filePath} + L{h.startLine}-{h.endLine} +
+
+ {h.language}{h.symbol ? ` · ${h.symbol}` : ''} · score {h.score.toFixed(3)} +
+
+ +
+ {/each} +
+{:else if tookMs !== null} +
No results.
+{/if} + + diff --git a/trueref-frontend/web/static/favicon.svg b/trueref-frontend/web/static/favicon.svg new file mode 100644 index 0000000..d20d471 --- /dev/null +++ b/trueref-frontend/web/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/trueref-frontend/web/svelte.config.js b/trueref-frontend/web/svelte.config.js new file mode 100644 index 0000000..10e36e2 --- /dev/null +++ b/trueref-frontend/web/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: false + }) + } +}; + +export default config; diff --git a/trueref-frontend/web/tsconfig.json b/trueref-frontend/web/tsconfig.json new file mode 100644 index 0000000..4344710 --- /dev/null +++ b/trueref-frontend/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/trueref-frontend/web/vite.config.ts b/trueref-frontend/web/vite.config.ts new file mode 100644 index 0000000..d712ce4 --- /dev/null +++ b/trueref-frontend/web/vite.config.ts @@ -0,0 +1,13 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + proxy: { + '/api': { target: 'http://localhost:8080', changeOrigin: true, ws: false }, + '/mcp': { target: 'http://localhost:8080', changeOrigin: true, ws: false }, + '/actuator': { target: 'http://localhost:8080', changeOrigin: true, ws: false } + } + } +});