diff --git a/.assets/menu-bar-client.png b/.assets/menu-bar-client.png new file mode 100644 index 00000000..1cf2d4c0 Binary files /dev/null and b/.assets/menu-bar-client.png differ diff --git a/.easignore b/.easignore new file mode 100644 index 00000000..a5360919 --- /dev/null +++ b/.easignore @@ -0,0 +1,73 @@ +# .easignore at repo root — used by EAS because this is a monorepo and +# the git root is the upload root. Must cover both the monorepo top-level +# and the app/ subdir (EAS ignores .gitignore when this file exists). + +# --- Sibling projects not needed for the Expo build --- +backends/ +extras/ +tests/ +docs/ +sdk/ +untracked/ + +# --- Root-level junk / non-code artifacts --- +*.log +*.env* +asc-api-key.p8 +**/asc-api-key.p8 +*.m4a +*.wav +plan.md +sample-voice-response.json +deepgram_response.json +init-feedback +*.ipa +*.apk +*.aab +connection-logging-*.md +memory-service-settings.md +docs-consolidation-analysis.md +BLE_OPTIMIZATION.md +.github/ +.cursor/ +.claude/ + +# --- Inside app/ (the EAS project) --- +# Dependencies — reinstalled on EAS build server +app/node_modules/ +# Expo / Metro +app/.expo/ +app/dist/ +app/web-build/ +app/.metro-health-check* +# Native build outputs — regenerated on EAS +app/android/.gradle/ +app/android/build/ +app/android/app/build/ +app/android/app/.cxx/ +app/ios/build/ +app/ios/Pods/ +app/ios/DerivedData/ +app/ios/*.xcworkspace/xcuserdata/ +app/ios/*.xcodeproj/xcuserdata/ +app/ios/*.xcodeproj/project.xcworkspace/xcuserdata/ +# Local artifacts +app/*.ipa +app/*.apk +app/*.aab +app/build-*.ipa +# Credentials (EAS uses server-side credentials) +app/*.jks +app/*.p8 +app/*.p12 +app/*.key +app/*.mobileprovision +app/*.pem +# Logs +app/npm-debug.* +app/yarn-debug.* +app/yarn-error.* + +# --- OS / editor --- +.DS_Store +**/.DS_Store diff --git a/.env.template b/.env.template index 388edbf5..7f256bf1 100644 --- a/.env.template +++ b/.env.template @@ -32,7 +32,6 @@ BACKEND_PORT=8000 WEBUI_PORT=5173 SPEAKER_PORT=8085 MONGODB_PORT=27017 -QDRANT_PORT=6333 NGROK_PORT=4040 # Kubernetes node ports (for LoadBalancer services) @@ -64,7 +63,7 @@ ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secure-admin-password # CORS origins (auto-generated based on DOMAIN and ports) -CORS_ORIGINS=http://${DOMAIN}:${WEBUI_PORT},http://${DOMAIN}:3000,http://localhost:${WEBUI_PORT},http://localhost:3000 +CORS_ORIGINS=http://${DOMAIN}:${WEBUI_PORT},http://localhost:${WEBUI_PORT} # ======================================== # LLM CONFIGURATION @@ -107,28 +106,13 @@ PARAKEET_ASR_URL=http://host.docker.internal:8767 MONGODB_URI=mongodb://mongo:${MONGODB_PORT} MONGODB_K8S_URI=mongodb://mongodb.${INFRASTRUCTURE_NAMESPACE}.svc.cluster.local:27017/chronicle -# Qdrant configuration -QDRANT_BASE_URL=qdrant -QDRANT_K8S_URL=qdrant.${INFRASTRUCTURE_NAMESPACE}.svc.cluster.local - -# Neo4j configuration (optional) -NEO4J_HOST=neo4j-mem0 -NEO4J_USER=neo4j -NEO4J_PASSWORD=neo4j-password - # ======================================== # MEMORY PROVIDER CONFIGURATION # ======================================== -# Memory Provider: chronicle or openmemory_mcp +# Memory Provider: chronicle (agentic Markdown vault) — the only provider MEMORY_PROVIDER=chronicle -# OpenMemory MCP configuration (when MEMORY_PROVIDER=openmemory_mcp) -OPENMEMORY_MCP_URL=http://host.docker.internal:8765 -OPENMEMORY_CLIENT_NAME=chronicle -OPENMEMORY_USER_ID=openmemory -OPENMEMORY_TIMEOUT=30 - # ======================================== # SPEAKER RECOGNITION CONFIGURATION # ======================================== @@ -218,4 +202,4 @@ WEBUI_MEMORY_REQUEST=128Mi SPEAKER_CPU_LIMIT=2000m SPEAKER_MEMORY_LIMIT=4Gi SPEAKER_CPU_REQUEST=500m -SPEAKER_MEMORY_REQUEST=2Gi \ No newline at end of file +SPEAKER_MEMORY_REQUEST=2Gi diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 0b8987c5..bacc73ab 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -32,7 +32,7 @@ on: - **No secrets required** - Works for external contributors - **Excludes**: Tests tagged with `requires-api-keys` - **Config**: `tests/configs/mock-services.yml` -- **Test Script**: `./run-no-api-tests.sh` +- **Makefile Target**: `make test-no-api OUTPUTDIR=results-no-api` - **Results**: `results-no-api/` - **Time**: ~10-15 minutes - **Coverage**: ~70% of test suite @@ -312,7 +312,7 @@ runs-on: ubuntu-latest CLEANUP_CONTAINERS: "false" # Handled by workflow # API keys if needed run: | - ./run-{no-api|robot}-tests.sh + make test-no-api # or ./run-robot-tests.sh for full suite TEST_EXIT_CODE=$? echo "test_exit_code=$TEST_EXIT_CODE" >> $GITHUB_ENV exit 0 # Don't fail yet diff --git a/.github/workflows/advanced-docker-compose-build.yml b/.github/workflows/advanced-docker-compose-build.yml index 93e72d68..f04088d5 100644 --- a/.github/workflows/advanced-docker-compose-build.yml +++ b/.github/workflows/advanced-docker-compose-build.yml @@ -13,7 +13,6 @@ on: - "backends/advanced/**" - "extras/asr-services/**" - "extras/speaker-recognition/**" - - "extras/openmemory-mcp/**" - ".github/workflows/advanced-docker-compose-build.yml" release: types: [ published ] @@ -28,27 +27,55 @@ env: REGISTRY: ghcr.io jobs: - build-default: + build-images: + name: Build ${{ matrix.image }} runs-on: ubuntu-latest timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - image: chronicle-backend + service: chronicle-backend + source-image: chronicle-backend:latest + directory: backends/advanced + variant: "" + - image: chronicle-asr-nemo-cu126 + service: parakeet-asr + source-image: parakeet-asr:latest + directory: extras/asr-services + variant: cu126 + - image: chronicle-asr-nemo-cu128 + service: parakeet-asr + source-image: parakeet-asr:latest + directory: extras/asr-services + variant: cu128 + - image: chronicle-speaker-cpu + service: speaker-service + source-image: chronicle-speaker:latest + directory: extras/speaker-recognition + variant: cpu + - image: chronicle-speaker-cu126 + service: speaker-service + source-image: chronicle-speaker:latest + directory: extras/speaker-recognition + variant: cu126 + - image: chronicle-speaker-cu128 + service: speaker-service + source-image: chronicle-speaker:latest + directory: extras/speaker-recognition + variant: cu128 env: ADVANCED_ENV: ${{ secrets.ADVANCED_ENV }} - RUNNER_FLAVOUR: ubuntu-latest - defaults: - run: - shell: bash - working-directory: backends/advanced steps: - - name: Show selected runner - run: echo "Workflow running on ${RUNNER_FLAVOUR} runner" - working-directory: . - - name: Checkout uses: actions/checkout@v4 - name: Print commit details + working-directory: ${{ matrix.directory }} run: | + echo "Image: ${{ matrix.image }}" echo "Event: ${{ github.event_name }}" echo "Ref: $GITHUB_REF" echo "Ref name: ${{ github.ref_name }}" @@ -69,44 +96,25 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Copy .env.template to .env + - name: Copy .env template + working-directory: ${{ matrix.directory }} run: | set -euo pipefail - copy_env() { - local dir="$1" - local template="${dir}/.env.template" - local target="${dir}/.env" - if [ -f "$template" ]; then - echo "Copying $template to $target" - cp "$template" "$target" - else - echo "$template not found; skipping" - fi - } - - copy_env . - copy_env ../../extras/asr-services - copy_env ../../extras/speaker-recognition - copy_env ../../extras/openmemory-mcp + if [ -f .env.template ]; then + cp .env.template .env + else + touch .env + fi - name: Create .env from secret (if provided) - if: env.ADVANCED_ENV != '' + if: matrix.image == 'chronicle-backend' && env.ADVANCED_ENV != '' + working-directory: ${{ matrix.directory }} run: | echo "Writing .env from ADVANCED_ENV secret" printf "%s\n" "${ADVANCED_ENV}" > .env - - name: Source .env (if present) - run: | - if [ -f .env ]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a - else - echo ".env not found; continuing" - fi - - name: Free Disk Space + working-directory: ${{ matrix.directory }} run: | echo "Freeing disk space..." df -h @@ -119,6 +127,7 @@ jobs: - name: Determine version id: version + working-directory: ${{ matrix.directory }} run: | if [ -n "${{ github.event.inputs.version }}" ]; then VERSION="${{ github.event.inputs.version }}" @@ -132,151 +141,40 @@ jobs: echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - - name: Build, tag, and push services sequentially with version + - name: Build, tag, and push ${{ matrix.image }} + working-directory: ${{ matrix.directory }} env: OWNER: ${{ github.repository_owner }} VERSION: ${{ steps.version.outputs.VERSION }} + CHRONICLE_BUILD_VERSION: ${{ steps.version.outputs.VERSION }} + PYTORCH_CUDA_VERSION: ${{ matrix.variant }} run: | set -euo pipefail docker compose version OWNER_LC=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]') - - # CUDA variants from pyproject.toml - CUDA_VARIANTS=("cpu" "cu121" "cu126" "cu128") - - # Base services (no CUDA variants, no profiles) - base_service_specs=( - "chronicle-backend|advanced-chronicle-backend|docker-compose.yml|." - "workers|advanced-workers|docker-compose.yml|." - "webui|advanced-webui|docker-compose.yml|." - "openmemory-mcp|openmemory-mcp|../../extras/openmemory-mcp/docker-compose.yml|../../extras/openmemory-mcp" - ) - - # Build and push base services - for spec in "${base_service_specs[@]}"; do - IFS='|' read -r svc svc_repo compose_file project_dir <<< "$spec" - - echo "::group::Building and pushing $svc_repo" - if [ "$compose_file" = "docker-compose.yml" ] && [ "$project_dir" = "." ]; then - docker compose build --pull "$svc" - else - docker compose -f "$compose_file" --project-directory "$project_dir" build "$svc" - fi - # Resolve the built image ID via compose (avoids name mismatches) - if [ "$compose_file" = "docker-compose.yml" ] && [ "$project_dir" = "." ]; then - img_id=$(docker compose images -q "$svc" | head -n1) - else - img_id=$(docker compose -f "$compose_file" --project-directory "$project_dir" images -q "$svc" | head -n1) - fi - if [ -z "${img_id:-}" ]; then - echo "Skipping $svc_repo (no built image found after build)" - echo "::endgroup::" - continue - fi - - # Tag and push with version - target_image="$REGISTRY/$OWNER_LC/$svc_repo:$VERSION" - latest_image="$REGISTRY/$OWNER_LC/$svc_repo:latest" - echo "Tagging $img_id as $target_image" - docker tag "$img_id" "$target_image" - echo "Tagging $img_id as $latest_image" - docker tag "$img_id" "$latest_image" - - echo "Pushing $target_image" - docker push "$target_image" - echo "Pushing $latest_image" - docker push "$latest_image" - - # Clean up local tags - docker image rm -f "$target_image" || true - docker image rm -f "$latest_image" || true - echo "::endgroup::" - - # Aggressive cleanup to save space - docker system prune -af || true - done - - # Build and push parakeet-asr with CUDA variants (cu121, cu126, cu128) - echo "::group::Building and pushing parakeet-asr CUDA variants" - cd ../../extras/asr-services - for cuda_variant in cu121 cu126 cu128; do - echo "Building parakeet-asr-${cuda_variant}" - export PYTORCH_CUDA_VERSION="${cuda_variant}" - docker compose build parakeet-asr - - img_id=$(docker compose images -q parakeet-asr | head -n1) - if [ -n "${img_id:-}" ]; then - target_image="$REGISTRY/$OWNER_LC/parakeet-asr-${cuda_variant}:$VERSION" - latest_image="$REGISTRY/$OWNER_LC/parakeet-asr-${cuda_variant}:latest" - echo "Tagging $img_id as $target_image" - docker tag "$img_id" "$target_image" - echo "Tagging $img_id as $latest_image" - docker tag "$img_id" "$latest_image" - - echo "Pushing $target_image" - docker push "$target_image" - echo "Pushing $latest_image" - docker push "$latest_image" - - # Clean up local tags - docker image rm -f "$target_image" || true - docker image rm -f "$latest_image" || true - fi - - # Aggressive cleanup to save space - docker system prune -af || true - done - cd - > /dev/null - echo "::endgroup::" - - # Build and push speaker-recognition with all CUDA variants (including CPU) - # Note: speaker-service has profiles, but we can build it directly by setting PYTORCH_CUDA_VERSION - echo "::group::Building and pushing speaker-recognition variants" - cd ../../extras/speaker-recognition - for cuda_variant in "${CUDA_VARIANTS[@]}"; do - echo "Building speaker-recognition-${cuda_variant}" - export PYTORCH_CUDA_VERSION="${cuda_variant}" - # Build speaker-service directly (profiles only affect 'up', not 'build') - docker compose build speaker-service - - img_id=$(docker compose images -q speaker-service | head -n1) - if [ -n "${img_id:-}" ]; then - target_image="$REGISTRY/$OWNER_LC/speaker-recognition-${cuda_variant}:$VERSION" - latest_image="$REGISTRY/$OWNER_LC/speaker-recognition-${cuda_variant}:latest" - echo "Tagging $img_id as $target_image" - docker tag "$img_id" "$target_image" - echo "Tagging $img_id as $latest_image" - docker tag "$img_id" "$latest_image" - - echo "Pushing $target_image" - docker push "$target_image" - echo "Pushing $latest_image" - docker push "$latest_image" - - # Clean up local tags - docker image rm -f "$target_image" || true - docker image rm -f "$latest_image" || true - fi - - # Aggressive cleanup to save space - docker system prune -af || true - done - cd - > /dev/null - echo "::endgroup::" - - # Summary - echo "::group::Build Summary" - echo "Built and pushed images with version tag: ${VERSION}" - echo "Images pushed to: $REGISTRY/$OWNER_LC/" - echo "::endgroup::" + docker compose build --pull "${{ matrix.service }}" + img_id=$(docker image inspect "${{ matrix.source-image }}" --format '{{.Id}}') + + target_image="$REGISTRY/$OWNER_LC/${{ matrix.image }}:$VERSION" + latest_image="$REGISTRY/$OWNER_LC/${{ matrix.image }}:latest" + docker tag "$img_id" "$target_image" + docker tag "$img_id" "$latest_image" + docker push "$target_image" + docker push "$latest_image" + + update-release: + name: Update release notes + needs: build-images + if: github.event_name == 'release' || inputs.version != '' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ github.repository_owner }} + VERSION: ${{ github.event.release.tag_name || inputs.version }} + TAG_NAME: ${{ github.event.release.tag_name || inputs.version }} + steps: - name: Update release notes with Docker images - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OWNER: ${{ github.repository_owner }} - VERSION: ${{ steps.version.outputs.VERSION }} - TAG_NAME: ${{ github.event.release.tag_name }} run: | set -euo pipefail OWNER_LC=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]') @@ -291,31 +189,26 @@ jobs: ### Core Services \`\`\`bash - docker pull ghcr.io/${OWNER_LC}/advanced-chronicle-backend:${VERSION} - docker pull ghcr.io/${OWNER_LC}/advanced-workers:${VERSION} - docker pull ghcr.io/${OWNER_LC}/advanced-webui:${VERSION} - docker pull ghcr.io/${OWNER_LC}/openmemory-mcp:${VERSION} + docker pull ghcr.io/${OWNER_LC}/chronicle-backend:${VERSION} \`\`\` ### Parakeet ASR (pick your CUDA version) \`\`\`bash - docker pull ghcr.io/${OWNER_LC}/parakeet-asr-cu121:${VERSION} - docker pull ghcr.io/${OWNER_LC}/parakeet-asr-cu126:${VERSION} - docker pull ghcr.io/${OWNER_LC}/parakeet-asr-cu128:${VERSION} + docker pull ghcr.io/${OWNER_LC}/chronicle-asr-nemo-cu126:${VERSION} + docker pull ghcr.io/${OWNER_LC}/chronicle-asr-nemo-cu128:${VERSION} \`\`\` ### Speaker Recognition (pick your variant) \`\`\`bash - docker pull ghcr.io/${OWNER_LC}/speaker-recognition-cpu:${VERSION} - docker pull ghcr.io/${OWNER_LC}/speaker-recognition-cu121:${VERSION} - docker pull ghcr.io/${OWNER_LC}/speaker-recognition-cu126:${VERSION} - docker pull ghcr.io/${OWNER_LC}/speaker-recognition-cu128:${VERSION} + docker pull ghcr.io/${OWNER_LC}/chronicle-speaker-cpu:${VERSION} + docker pull ghcr.io/${OWNER_LC}/chronicle-speaker-cu126:${VERSION} + docker pull ghcr.io/${OWNER_LC}/chronicle-speaker-cu128:${VERSION} \`\`\` EOF ) EXISTING_BODY=$(gh release view "$TAG_NAME" --json body -q '.body' --repo "$GITHUB_REPOSITORY") + EXISTING_BODY="${EXISTING_BODY%%$'\n---\n\n## Docker Images'*}" UPDATED_BODY="${EXISTING_BODY}${DOCKER_SECTION}" gh release edit "$TAG_NAME" --notes "$UPDATED_BODY" --repo "$GITHUB_REPOSITORY" echo "Release notes updated with Docker image info" - working-directory: . diff --git a/.github/workflows/android-apk-build.yml b/.github/workflows/android-apk-build.yml index 4434eb13..c08db287 100644 --- a/.github/workflows/android-apk-build.yml +++ b/.github/workflows/android-apk-build.yml @@ -18,11 +18,11 @@ jobs: defaults: run: working-directory: ./app - + steps: - name: Setup repo uses: actions/checkout@v4 - + - name: Setup node uses: actions/setup-node@v4.0.2 with: @@ -54,7 +54,7 @@ jobs: - name: Build Android APK run: eas build --platform android --profile local --local --output ${{ github.workspace }}/app-release.apk --non-interactive - + - name: Generate release tag id: tag run: | @@ -73,11 +73,11 @@ jobs: release_name: ${{ steps.tag.outputs.RELEASE_NAME }} body: | ## 📱 Android APK Build - + **Built from commit:** ${{ github.sha }} **Branch:** ${{ github.ref_name }} **Build time:** ${{ steps.tag.outputs.BUILD_TIME }} - + Ready to install on Android devices! draft: false prerelease: true @@ -90,4 +90,4 @@ jobs: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ${{ github.workspace }}/app-release.apk asset_name: friend-lite-android.apk - asset_content_type: application/vnd.android.package-archive \ No newline at end of file + asset_content_type: application/vnd.android.package-archive diff --git a/.github/workflows/build-all-platforms.yml b/.github/workflows/build-all-platforms.yml index e73e6147..9be5cb25 100644 --- a/.github/workflows/build-all-platforms.yml +++ b/.github/workflows/build-all-platforms.yml @@ -26,11 +26,11 @@ jobs: defaults: run: working-directory: ./app - + steps: - name: Setup repo uses: actions/checkout@v4 - + - name: Setup node uses: actions/setup-node@v4.0.2 with: @@ -80,14 +80,14 @@ jobs: release_name: ${{ steps.tag.outputs.RELEASE_NAME }} body: | ## 🚀 Automated Build - + **Built from commit:** ${{ github.sha }} **Branch:** ${{ github.ref_name }} **Build time:** ${{ steps.tag.outputs.BUILD_TIME }} - + ### 📱 Downloads - **Android APK**: Ready for installation on Android devices - + ### 🔧 Build Info - Built with GitHub Actions - Debug build (unsigned) @@ -112,11 +112,11 @@ jobs: defaults: run: working-directory: ./app - + steps: - name: Setup repo uses: actions/checkout@v4 - + - name: Setup node uses: actions/setup-node@v4.0.2 with: @@ -173,11 +173,11 @@ jobs: release_name: ${{ steps.ios_tag.outputs.IOS_RELEASE_NAME }} body: | ## 🍎 iOS IPA Build - + **Built from commit:** ${{ github.sha }} **Branch:** ${{ github.ref_name }} **Build time:** ${{ steps.ios_tag.outputs.IOS_BUILD_TIME }} - + For iOS Simulator testing! draft: false prerelease: true @@ -191,4 +191,4 @@ jobs: upload_url: ${{ steps.create_ios_release.outputs.upload_url }} asset_path: ${{ github.workspace }}/friend-lite-ios.ipa asset_name: friend-lite-ios.ipa - asset_content_type: application/octet-stream \ No newline at end of file + asset_content_type: application/octet-stream diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index ae36c007..3cf327b9 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -35,7 +35,7 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - + # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' - diff --git a/.github/workflows/full-tests-with-api.yml b/.github/workflows/full-tests-with-api.yml index b5881fcd..80fbe663 100644 --- a/.github/workflows/full-tests-with-api.yml +++ b/.github/workflows/full-tests-with-api.yml @@ -111,9 +111,9 @@ jobs: CLEANUP_CONTAINERS: "false" # Don't cleanup in CI - handled by workflow run: | # Use the full test script (includes all tests with API keys) - ./run-robot-tests.sh - TEST_EXIT_CODE=$? - echo "test_exit_code=$TEST_EXIT_CODE" >> $GITHUB_ENV + TEST_EXIT_CODE=0 + ./run-robot-tests.sh || TEST_EXIT_CODE=$? + echo "test_exit_code=$TEST_EXIT_CODE" >> "$GITHUB_ENV" exit 0 # Don't fail here, we'll fail at the end after uploading artifacts - name: Save service logs to files @@ -129,7 +129,6 @@ jobs: docker compose -f docker-compose-test.yml logs workers-test > logs/workers.log 2>&1 || true docker compose -f docker-compose-test.yml logs mongo-test > logs/mongo.log 2>&1 || true docker compose -f docker-compose-test.yml logs redis-test > logs/redis.log 2>&1 || true - docker compose -f docker-compose-test.yml logs qdrant-test > logs/qdrant.log 2>&1 || true docker compose -f docker-compose-test.yml logs speaker-service-test > logs/speaker.log 2>&1 || true echo "✓ Logs saved to backends/advanced/logs/" ls -lh logs/ @@ -256,8 +255,12 @@ jobs: - name: Fail workflow if tests failed if: always() run: | - if [ "${{ env.test_exit_code }}" != "0" ]; then - echo "❌ Tests failed with exit code ${{ env.test_exit_code }}" + TEST_EXIT_CODE="${{ env.test_exit_code }}" + if [ -z "$TEST_EXIT_CODE" ]; then + echo "❌ Test step did not record an exit code; check earlier setup/test steps" + exit 1 + elif [ "$TEST_EXIT_CODE" != "0" ]; then + echo "❌ Tests failed with exit code $TEST_EXIT_CODE" exit 1 else echo "✅ All tests passed" diff --git a/.github/workflows/ios-ipa-build.yml b/.github/workflows/ios-ipa-build.yml index dbd0c5bb..e3a02be3 100644 --- a/.github/workflows/ios-ipa-build.yml +++ b/.github/workflows/ios-ipa-build.yml @@ -18,11 +18,11 @@ jobs: defaults: run: working-directory: ./app - + steps: - name: Setup repo uses: actions/checkout@v4 - + - name: Setup node uses: actions/setup-node@v4.0.2 with: @@ -66,11 +66,11 @@ jobs: release_name: ${{ steps.tag.outputs.RELEASE_NAME }} body: | ## 🍎 iOS IPA Build - + **Built from commit:** ${{ github.sha }} **Branch:** ${{ github.ref_name }} **Build time:** ${{ steps.tag.outputs.BUILD_TIME }} - + For iOS Simulator testing! draft: false prerelease: true @@ -83,4 +83,4 @@ jobs: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ${{ github.workspace }}/app-release.ipa asset_name: friend-lite-ios.ipa - asset_content_type: application/octet-stream \ No newline at end of file + asset_content_type: application/octet-stream diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml new file mode 100644 index 00000000..8c468e35 --- /dev/null +++ b/.github/workflows/ios-testflight.yml @@ -0,0 +1,44 @@ +name: iOS TestFlight Deploy + +permissions: + contents: read + +on: + push: + branches: [main] + paths: ['app/**'] + workflow_dispatch: + +jobs: + build-and-submit: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app + + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4.0.2 + with: + node-version: 20.x + cache: 'npm' + cache-dependency-path: ./app/package-lock.json + + - name: Setup Expo + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Install dependencies + run: npm ci + + - name: Write ASC API Key + run: echo "${{ secrets.ASC_API_KEY_P8 }}" > asc-api-key.p8 + + - name: Build and submit to TestFlight + run: eas build --platform ios --profile testflight --auto-submit --non-interactive diff --git a/.github/workflows/pr-tests-with-api.yml b/.github/workflows/pr-tests-with-api.yml index aeb45b1c..8e4b1cd1 100644 --- a/.github/workflows/pr-tests-with-api.yml +++ b/.github/workflows/pr-tests-with-api.yml @@ -105,9 +105,9 @@ jobs: CLEANUP_CONTAINERS: "false" # Don't cleanup in CI - handled by workflow run: | # Use the full test script (includes all tests with API keys) - ./run-robot-tests.sh - TEST_EXIT_CODE=$? - echo "test_exit_code=$TEST_EXIT_CODE" >> $GITHUB_ENV + TEST_EXIT_CODE=0 + ./run-robot-tests.sh || TEST_EXIT_CODE=$? + echo "test_exit_code=$TEST_EXIT_CODE" >> "$GITHUB_ENV" exit 0 # Don't fail here, we'll fail at the end after uploading artifacts - name: Save service logs to files @@ -123,7 +123,6 @@ jobs: docker compose -f docker-compose-test.yml logs workers-test > logs/workers.log 2>&1 || true docker compose -f docker-compose-test.yml logs mongo-test > logs/mongo.log 2>&1 || true docker compose -f docker-compose-test.yml logs redis-test > logs/redis.log 2>&1 || true - docker compose -f docker-compose-test.yml logs qdrant-test > logs/qdrant.log 2>&1 || true docker compose -f docker-compose-test.yml logs speaker-service-test > logs/speaker.log 2>&1 || true echo "✓ Logs saved to backends/advanced/logs/" ls -lh logs/ @@ -295,8 +294,12 @@ jobs: - name: Fail workflow if tests failed if: always() run: | - if [ "${{ env.test_exit_code }}" != "0" ]; then - echo "❌ Tests failed with exit code ${{ env.test_exit_code }}" + TEST_EXIT_CODE="${{ env.test_exit_code }}" + if [ -z "$TEST_EXIT_CODE" ]; then + echo "❌ Test step did not record an exit code; check earlier setup/test steps" + exit 1 + elif [ "$TEST_EXIT_CODE" != "0" ]; then + echo "❌ Tests failed with exit code $TEST_EXIT_CODE" exit 1 else echo "✅ All tests passed" diff --git a/.github/workflows/robot-tests.yml b/.github/workflows/robot-tests.yml index 35e4dffa..1d3fffa7 100644 --- a/.github/workflows/robot-tests.yml +++ b/.github/workflows/robot-tests.yml @@ -76,13 +76,11 @@ jobs: - name: Run Robot Framework tests (No API Keys) working-directory: tests - env: - CLEANUP_CONTAINERS: "false" # Don't cleanup in CI - handled by workflow run: | - # Use the no-API test script (excludes tests tagged with requires-api-keys) - ./run-no-api-tests.sh - TEST_EXIT_CODE=$? - echo "test_exit_code=$TEST_EXIT_CODE" >> $GITHUB_ENV + # Use Makefile target (starts containers with mock config, excludes api-keys/slow/sdk/gpu tests) + TEST_EXIT_CODE=0 + make test-no-api OUTPUTDIR=results-no-api || TEST_EXIT_CODE=$? + echo "test_exit_code=$TEST_EXIT_CODE" >> "$GITHUB_ENV" exit 0 # Don't fail here, we'll fail at the end after uploading artifacts - name: Save service logs to files @@ -98,7 +96,6 @@ jobs: docker compose -f docker-compose-test.yml logs workers-test > logs/workers.log 2>&1 || true docker compose -f docker-compose-test.yml logs mongo-test > logs/mongo.log 2>&1 || true docker compose -f docker-compose-test.yml logs redis-test > logs/redis.log 2>&1 || true - docker compose -f docker-compose-test.yml logs qdrant-test > logs/qdrant.log 2>&1 || true docker compose -f docker-compose-test.yml logs speaker-service-test > logs/speaker.log 2>&1 || true echo "✓ Logs saved to backends/advanced/logs/" ls -lh logs/ @@ -273,8 +270,12 @@ jobs: - name: Fail workflow if tests failed if: always() run: | - if [ "${{ env.test_exit_code }}" != "0" ]; then - echo "❌ Tests failed with exit code ${{ env.test_exit_code }}" + TEST_EXIT_CODE="${{ env.test_exit_code }}" + if [ -z "$TEST_EXIT_CODE" ]; then + echo "❌ Test step did not record an exit code; check earlier setup/test steps" + exit 1 + elif [ "$TEST_EXIT_CODE" != "0" ]; then + echo "❌ Tests failed with exit code $TEST_EXIT_CODE" exit 1 else echo "✅ All tests passed" diff --git a/.github/workflows/speaker-recognition-tests.yml b/.github/workflows/speaker-recognition-tests.yml index 5768ada7..1cb1e027 100644 --- a/.github/workflows/speaker-recognition-tests.yml +++ b/.github/workflows/speaker-recognition-tests.yml @@ -28,7 +28,7 @@ jobs: speaker-recognition-tests: runs-on: ubuntu-latest timeout-minutes: 30 - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -53,17 +53,17 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - + - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - + - name: Run Speaker Recognition Integration Tests env: HF_TOKEN: ${{ secrets.HF_TOKEN }} @@ -71,7 +71,7 @@ jobs: run: | cd extras/speaker-recognition ./run-test.sh - + - name: Debug Docker build failure if: failure() run: | @@ -84,7 +84,7 @@ jobs: docker compose -f docker-compose-test.yml logs || true echo "=== Docker system info ===" docker system df || true - + - name: Upload test logs on failure if: failure() uses: actions/upload-artifact@v4 @@ -93,4 +93,4 @@ jobs: path: | extras/speaker-recognition/docker-compose-test.yml extras/speaker-recognition/.env - retention-days: 7 \ No newline at end of file + retention-days: 7 diff --git a/.gitignore b/.gitignore index 4b5c84d3..66c21082 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ **/__pycache__ *.wav +# Wake-word notification tones are bundled assets, not user audio — keep them tracked +# so they're baked into the wakeword-service image (and served to all clients). +!extras/wakeword-service/tones/*.wav **/*.env !**/.env.template **/memory_config.yaml @@ -15,6 +18,10 @@ config/config.yml config/plugins.yml !config/plugins.yml.template +# Advertised services — per-machine runtime state, regenerated by the node agent on +# every start (services.py / edge/service_manager.py). No defaults, so no template. +config/advertised-services.json + # Individual plugin configs (may contain user-specific settings) backends/advanced/src/advanced_omi_backend/plugins/*/config.yml !backends/advanced/src/advanced_omi_backend/plugins/*/config.yml.template @@ -22,11 +29,11 @@ backends/advanced/src/advanced_omi_backend/plugins/*/config.yml # Config backups config/*.backup.* config/*.backup* +plugins/*/config.yml.backup example/* **/node_modules/* **/ollama-data/* -**/qdrant_data/* **/model_cache/* .vscode/* **/audio_chunks/* @@ -53,6 +60,7 @@ untracked/* backends/advanced/data/* backends/advanced/diarization_config.json extras/local-wearable-client/devices.yml +extras/llm-services/cache/** !extras/local-wearable-client/devices.yml.template extras/havpe-relay/firmware/secrets.yaml extras/test-audios/* @@ -64,23 +72,37 @@ extras/test-audios/* extras/speaker-omni-experimental/data/* extras/speaker-omni-experimental/cache/* +# Discovery agent PID file +edge/.discovery-agent.pid + # AI Stuff .claude -# SSL +# SSL / TLS certificates (centralized) +certs/*.crt +certs/*.key +certs/*.pem + +# Legacy per-service SSL (kept for cleanup) extras/speaker-recognition/ssl/* backends/advanced/ssl/* -# nginx +# Generated reverse proxy configs extras/speaker-recognition/nginx.conf +extras/speaker-recognition/Caddyfile + +# Generated compose overrides (e.g. Tailscale socket mount for Caddy-managed certs) +backends/advanced/docker-compose.override.yml +extras/speaker-recognition/docker-compose.override.yml # Cache extras/speaker-recognition/cache/* extras/speaker-recognition/outputs/* +**/model_cache_strix_test/* # my backup backends/advanced/src/_webui_original/* -backends/advanced-backend/data/neo4j_data/* +backends/advanced-backend/data/falkordb_data/* backends/advanced-backend/data/speaker_model_cache/ *.bin @@ -106,3 +128,5 @@ report.html .secrets sdk/ + +edge/.discovery-agent.pid diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6ebb6573..859eca3c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,42 +1,22 @@ repos: - # Local hooks (project-specific checks) - - repo: local - hooks: - # Run Robot Framework endpoint tests before push - - id: robot-framework-tests - name: Robot Framework Tests (Endpoints) - entry: bash -c 'cd tests && make endpoints OUTPUTDIR=.pre-commit-results' - language: system - pass_filenames: false - stages: [push] - verbose: true - - # Clean up test results after hook runs - - id: cleanup-test-results - name: Cleanup Test Results - entry: bash -c 'cd tests && rm -rf .pre-commit-results' - language: system - pass_filenames: false - stages: [push] - always_run: true - # Code formatting - repo: https://github.com/psf/black rev: 24.4.2 hooks: - id: black - files: ^backends/advanced-backend/src/.*\.py$ + exclude: \.venv/ - repo: https://github.com/PyCQA/isort rev: 5.13.2 hooks: - id: isort - files: ^backends/advanced-backend/src/.*\.py$ + args: ["--profile", "black"] + exclude: \.venv/ # File hygiene - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: trailing-whitespace - files: ^backends/advanced-backend/src/.* + exclude: \.venv/ - id: end-of-file-fixer - files: ^backends/advanced-backend/src/.* \ No newline at end of file + exclude: \.venv/ diff --git a/CLAUDE.md b/AGENTS.md similarity index 74% rename from CLAUDE.md rename to AGENTS.md index fc3d8818..e07cf736 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# CLAUDE.md +# AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to coding agents working in this repository. ## Project Overview @@ -20,7 +20,7 @@ Chronicle includes an **interactive setup wizard** for easy configuration. The w - Authentication setup (admin account, JWT secrets) - Transcription provider configuration (Deepgram or offline ASR) - LLM provider setup (OpenAI or Ollama) -- Memory provider selection (Chronicle Native with Qdrant or OpenMemory MCP) +- Memory configuration (agentic Markdown vault — Chronicle's single memory provider) - Network configuration and HTTPS setup - Optional services (speaker recognition, Parakeet ASR) @@ -40,14 +40,14 @@ uv run --with-requirements setup-requirements.txt python wizard.py ### Setup Documentation For detailed setup instructions and troubleshooting, see: - **[@quickstart.md](quickstart.md)**: Beginner-friendly step-by-step setup guide -- **[@Docs/init-system.md](Docs/init-system.md)**: Complete initialization system architecture and design +- **[@docs/init-system.md](docs/init-system.md)**: Complete initialization system architecture and design ### Wizard Architecture The initialization system uses a **root orchestrator pattern**: - **`wizard.py`**: Root setup orchestrator for service selection and delegation - **`backends/advanced/init.py`**: Backend configuration wizard - **`extras/speaker-recognition/init.py`**: Speaker recognition setup -- **Service setup scripts**: Individual setup for ASR services and OpenMemory MCP +- **Service setup scripts**: Individual setup for ASR services Key features: - Interactive prompts with validation @@ -129,7 +129,7 @@ make logs SERVICE= # View specific service logs #### Test Environment Test services use isolated ports and database: -- **Ports:** Backend (8001), MongoDB (27018), Redis (6380), Qdrant (6337/6338) +- **Ports:** Backend (8001), MongoDB (27018), Redis (6380) - **Database:** `test_db` (separate from production) - **Credentials:** `test-admin@example.com` / `test-admin-password-123` @@ -162,6 +162,13 @@ docker compose up --build # HAVPE Relay (ESP32 bridge) cd extras/havpe-relay docker compose up --build + +# TTS Services (text-to-speech, run ONE provider at a time on port 8770) +cd extras/tts +docker compose up tada-tts -d --build # HumeAI TADA (GPU, voice cloning) +docker compose up fish-tts -d --build # Fish Speech (GPU, 50+ langs, emotion tags) +docker compose up kittentts-tts -d --build # KittenTTS (~25MB CPU ONNX, no GPU) +docker compose up kokoro-tts -d --build # Kokoro-82M (<~1GB VRAM GPU/CPU, preset voices) ``` ## Architecture Overview @@ -173,10 +180,10 @@ docker compose up --build - **Job Tracker**: Tracks pipeline jobs with stage events (audio → transcription → memory) and completion status - **Task Management**: BackgroundTaskManager tracks all async tasks to prevent orphaned processes - **Unified Transcription**: Deepgram transcription with fallback to offline ASR services -- **Memory System**: Pluggable providers (Chronicle native or OpenMemory MCP) +- **Memory System**: Single agentic Markdown vault — a tool-calling memory agent records conversations and surgically edits Obsidian-style People/Topic/Category notes; a read-only retrieval agent drives ripgrep over the vault to answer queries - **Authentication**: Email-based login with MongoDB ObjectId user system - **Client Management**: Auto-generated client IDs as `{user_id_suffix}-{device_name}`, centralized ClientManager -- **Data Storage**: MongoDB (`audio_chunks` collection for conversations), vector storage (Qdrant or OpenMemory) +- **Data Storage**: MongoDB (conversations, `audio_chunks`, chat, annotations), disk WAV files, and the Markdown vault (`data/conversation_docs//`) as the memory source of truth - **Web Interface**: React-based web dashboard with authentication and real-time monitoring ### Service Dependencies @@ -184,9 +191,8 @@ docker compose up --build Required: - MongoDB: User data and conversations - Redis: Job queues (RQ workers) and session state - - Qdrant: Vector storage for memory search - FastAPI Backend: Core audio processing - - LLM Service: Memory extraction and action items (OpenAI or Ollama) + - LLM Service: Memory agent (vault read/write) and action items (OpenAI or Ollama) Recommended: - Transcription: Deepgram or offline ASR services @@ -195,7 +201,6 @@ Optional: - Parakeet ASR: Offline transcription service - Speaker Recognition: Voice identification service - Caddy: HTTPS reverse proxy (auto-configured when HTTPS enabled) - - OpenMemory MCP: For cross-client memory compatibility ``` ## Data Flow Architecture @@ -206,8 +211,8 @@ Optional: 4. **Speech-Driven Conversation Creation**: User-facing conversations only created when speech is detected 5. **Dual Storage System**: Audio sessions always stored in `audio_chunks`, conversations created in `conversations` collection only with speech 6. **Versioned Processing**: Transcript and memory versions tracked with active version pointers -7. **Memory Processing**: Pluggable providers (Chronicle native with individual facts or OpenMemory MCP delegation) -8. **Memory Storage**: Direct Qdrant (Chronicle) or OpenMemory server (MCP provider) +7. **Memory Processing**: A tool-calling memory agent records each conversation and surgically edits People/Topic/Category notes in the Markdown vault +8. **Memory Storage**: Obsidian-style Markdown vault at `data/conversation_docs//` — the single source of truth, searched by a read-only retrieval agent via ripgrep 9. **Audio Optimization**: Speech segment extraction removes silence automatically 10. **Task Tracking**: BackgroundTaskManager ensures proper cleanup of all async operations @@ -256,51 +261,31 @@ DEEPGRAM_API_KEY=your-deepgram-key-here # Optional: TRANSCRIPTION_PROVIDER=deepgram # Memory Provider -MEMORY_PROVIDER=chronicle # or openmemory_mcp +MEMORY_PROVIDER=chronicle # agentic Markdown vault (only valid value) # Database MONGODB_URI=mongodb://mongo:27017 # Database name: chronicle -QDRANT_BASE_URL=qdrant # Network Configuration HOST_IP=localhost BACKEND_PUBLIC_PORT=8000 -WEBUI_PORT=3010 # Production port (5173 is Vite dev server only) -CORS_ORIGINS=http://localhost:3010,http://localhost:8000 +WEBUI_PORT=5173 # Vite dev server (the only webui; fronted by Caddy for HTTPS) +CORS_ORIGINS=http://localhost:5173,http://localhost:8000 ``` ### Memory Provider Configuration -Chronicle supports two pluggable memory backends: +Chronicle has a single memory provider, `chronicle`: an **agentic Markdown vault**. The vault (Obsidian-style notes at `data/conversation_docs//`) is the single source of truth. A tool-calling memory agent records each conversation and surgically edits People/Topic/Category notes; a read-only retrieval agent drives ripgrep over the vault to synthesize answers. There is no provider choice to configure — only an LLM for the agents to use. -#### Chronicle Memory Provider (Default) ```bash -# Use Chronicle memory provider (default) +# Memory provider (only valid value) MEMORY_PROVIDER=chronicle -# LLM Configuration for memory extraction +# LLM Configuration for the memory agent LLM_PROVIDER=openai OPENAI_API_KEY=your-openai-key-here OPENAI_MODEL=gpt-4o-mini - -# Vector Storage -QDRANT_BASE_URL=qdrant -``` - -#### OpenMemory MCP Provider -```bash -# Use OpenMemory MCP provider -MEMORY_PROVIDER=openmemory_mcp - -# OpenMemory MCP Server Configuration -OPENMEMORY_MCP_URL=http://host.docker.internal:8765 -OPENMEMORY_CLIENT_NAME=chronicle -OPENMEMORY_USER_ID=openmemory -OPENMEMORY_TIMEOUT=30 - -# OpenAI key for OpenMemory server -OPENAI_API_KEY=your-openai-key-here ``` ### Transcription Provider Configuration @@ -357,7 +342,7 @@ SPEAKER_SERVICE_URL=http://speaker-recognition:8085 - **GET /readiness**: Service dependency validation - **WS /ws**: Audio streaming endpoint with codec parameter (Wyoming protocol, supports pcm and opus codecs) - **GET /api/conversations**: User's conversations with transcripts -- **GET /api/memories/search**: Semantic memory search with relevance scoring +- **GET /api/memories/search**: Agentic vault search (retrieval agent over the Markdown vault) - **POST /auth/jwt/login**: Email-based login (returns JWT token) ### Authentication Flow @@ -458,6 +443,51 @@ The relay will automatically: - Forward ESP32 audio to the backend with proper authentication - Handle token refresh and reconnection +## TTS Services + +Provider-based text-to-speech (`extras/tts/`), built on the same provider pattern as `extras/asr-services/`. Run **one provider at a time**, all serving on port `8770` (configurable via `TTS_PORT`). + +### Providers + +| Provider | Service | Hardware | Highlights | +|----------|---------|----------|-----------| +| **TADA** (HumeAI) | `tada-tts` | GPU | Zero-shot voice cloning, 1:1 token alignment (no hallucinations), MIT. `tada-1b` (English) / `tada-3b-ml` (9 langs). Needs `HF_TOKEN` (Llama 3.2 base is gated). | +| **Fish Speech** (Fish Audio) | `fish-tts` | GPU | Dual-AR, 50+ langs, inline emotion/prosody tags (`[laugh]`, `[whispers]`), streaming. `s2-pro` (default) / `openaudio-s1-mini` / `fish-speech-1.5`. Optional `torch.compile`. | +| **KittenTTS** (KittenML) | `kittentts-tts` | CPU | Ultra-light (~25MB) ONNX, no GPU/API key, preset voices, English only. Uses dedicated `KITTEN_TTS_*` env vars. | +| **Kokoro** (hexgrad) | `kokoro-tts` | GPU/CPU | Lightweight (~82M, **<~1GB VRAM**) StyleTTS2, preset voices, 8 langs, Apache-2.0. Quality-per-VRAM sweet spot. Uses dedicated `KOKORO_TTS_*` env vars. | + +### Setup & Run + +```bash +cd extras/tts + +# Configure (selects provider, model, CUDA version) +uv run --with-requirements ../../setup-requirements.txt python init.py + +# Start ONE provider +docker compose up tada-tts -d --build # or fish-tts / kittentts-tts + +# Test +curl http://localhost:8770/health +curl -X POST http://localhost:8770/synthesize -F "text=Hello world." -o output.wav +``` + +### API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Service health (`healthy` / `initializing`) | +| `/info` | GET | Model id, provider, capabilities, supported languages | +| `/synthesize` | POST | Generate speech (multipart form) | + +**POST /synthesize** — `text` (required); optional `reference_audio` (WAV) + `reference_text` for voice cloning; optional generation params (`temperature`, `top_p`, `repetition_penalty`, `seed`, `max_new_tokens`). Returns WAV bytes with `X-Sample-Rate`, `X-Provider`, `X-Model` headers. + +**Notes:** +- Not registered in `services.py` — manage with `docker compose` directly (like the HAVPE relay). +- GPU providers require CUDA 12.6+ (`PYTORCH_CUDA_VERSION=cu126`/`cu128`); `cu121` is unsupported (torch>=2.7). +- Add a provider by creating `extras/tts/providers/{name}/` with `service.py`, `synthesizer.py`, and `Dockerfile` (subclass `BaseTTSService`). +- An optional `edge-agent` sidecar (`--profile edge`) advertises the service on the Tailnet. + ## Distributed Deployment ### Single Machine vs Distributed Setup @@ -515,14 +545,14 @@ tailscale ip -4 **Service Examples:** - GPU machine: LLM inference, ASR, speaker recognition - Backend machine: FastAPI, WebUI, databases -- Database machine: MongoDB, Qdrant (optional separation) +- Database machine: MongoDB (optional separation) ## Development Notes ### Package Management - **Backend**: Uses `uv` for Python dependency management (faster than pip) - **Mobile**: Uses `npm` with React Native and Expo -- **Docker**: Primary deployment method with docker-compose +- **Containers**: The service lifecycle (`services.py`/`status.py`/service-manager) supports **both Docker and Podman**, selected via `container_engine: docker|podman` in `config/config.yml` (or the `CONTAINER_ENGINE`/`COMPOSE_CMD` env vars). The repo's compose files run unmodified under either engine. For Podman it drives `podman-compose` and needs CDI for GPU. See **[@docs/podman.md](docs/podman.md)** for rootless/GPU setup and migration notes. ### Testing Strategy - **Makefile-Based**: All test operations through simple `make` commands (`make test`, `make start`, `make stop`) @@ -562,20 +592,18 @@ The system includes comprehensive health checks: - **Log Preservation**: All cleanup operations save logs to `tests/logs/` automatically - **CI Compatibility**: Same test logic runs locally and in GitHub Actions -### Cursor Rule Integration -Project includes `.cursor/rules/always-plan-first.mdc` requiring understanding before coding. Always explain the task and confirm approach before implementation. - ## Extended Documentation For detailed technical documentation, see: -- **[@Docs/overview.md](Docs/overview.md)**: Architecture overview and technical deep dive -- **[@Docs/init-system.md](Docs/init-system.md)**: Initialization system and service management -- **[@Docs/ssl-certificates.md](Docs/ssl-certificates.md)**: HTTPS/SSL setup details -- **[@Docs/audio-pipeline-architecture.md](Docs/audio-pipeline-architecture.md)**: Audio pipeline design -- **[@backends/advanced/Docs/auth.md](backends/advanced/Docs/auth.md)**: Authentication architecture -- **[backends/advanced/Docs/architecture.md](backends/advanced/Docs/architecture.md)**: Backend architecture details -- **[@backends/advanced/Docs/memories.md](backends/advanced/Docs/memories.md)**: Memory system documentation -- **[@backends/advanced/Docs/plugin-development-guide.md](backends/advanced/Docs/plugin-development-guide.md)**: Plugin development guide +- **[@docs/README.md](docs/README.md)**: Documentation index +- **[@docs/overview.md](docs/overview.md)**: Architecture overview and technical deep dive +- **[@docs/init-system.md](docs/init-system.md)**: Initialization system and service management +- **[@docs/ssl-certificates.md](docs/ssl-certificates.md)**: HTTPS/SSL setup details +- **[@docs/podman.md](docs/podman.md)**: Running with Podman instead of Docker (engine selection, rootless/GPU setup) +- **[@docs/audio-pipeline-architecture.md](docs/audio-pipeline-architecture.md)**: Audio pipeline design +- **[@docs/backend/auth.md](docs/backend/auth.md)**: Authentication architecture +- **[@docs/backend/memories.md](docs/backend/memories.md)**: Memory system documentation +- **[@docs/backend/plugin-development-guide.md](docs/backend/plugin-development-guide.md)**: Plugin development guide ## Robot Framework Testing @@ -602,15 +630,18 @@ Key Testing Rules: - Use space-separated tags (must be tab-separated) - Skip reading the guidelines before writing tests -## Notes for Claude +## Notes for Coding Agents Check if the src/ is volume mounted. If not, do compose build so that code changes are reflected. Do not simply run `docker compose restart` as it will not rebuild the image. -Check backends/advanced/Docs for up to date information on advanced backend. +Check `docs/backend/` for up-to-date information on the advanced backend. All docker projects have .dockerignore following the exclude pattern. That means files need to be included for them to be visible to docker. The uv package manager is used for all python projects. Wherever you'd call `python3 main.py` you'd call `uv run python main.py` +**Container Engine (Docker or Podman):** +- The project supports **both Docker and Podman**. The active engine is set by `container_engine` in `config/config.yml` (default `docker`); prefer the lifecycle scripts (`./start.sh`/`./stop.sh`/`./restart.sh`) which route through the selected engine. For one-off manual commands under Podman use `podman-compose` (not `docker compose`). See **[@docs/podman.md](docs/podman.md)**. + **Docker Build Guidelines:** -- Use `docker compose build` without `--no-cache` by default for faster builds +- Use `docker compose build` (or `podman-compose build`) without `--no-cache` by default for faster builds - Only use `--no-cache` when explicitly needed (e.g., if cached layers are causing issues or when troubleshooting build problems) -- Docker's build cache is efficient and saves significant time during development +- The build cache is efficient and saves significant time during development -- Remember that whenever there's a python command, you should use uv run python3 instead \ No newline at end of file +- Remember that whenever there's a python command, you should use uv run python3 instead diff --git a/CONNECTION_RESILIENCE_AND_UX_PLAN.md b/CONNECTION_RESILIENCE_AND_UX_PLAN.md new file mode 100644 index 00000000..0731b748 --- /dev/null +++ b/CONNECTION_RESILIENCE_AND_UX_PLAN.md @@ -0,0 +1,294 @@ +# Connection Resilience & UX — Unified Plan + +> **Implementation status (branch `fix/optimizations-2`): IMPLEMENTED.** +> Done: Phase 0 (backend pong), Track 1 (1A BLE device-forget, 1B central auth +> manager + SecureStore + logout/forget split, 1C persistent WS retry, 1D zombie +> ping/pong, 1E foreground/relaunch reconnect, 1F Connection Doctor probe ladder +> + Tailscale-aware messaging), Track 2 (QR JSON bundle parse, SM URL/token +> storage, `serviceManager.ts` proxy+direct client, `NetworkOverview` screen with +> start/stop/restart, webui QR bundle), Track 3 (wearable + HAVPE relay reconnect). +> App `tsc --noEmit` clean; all edited Python `py_compile`s. +> **Two deliberate deferrals:** (a) 1F "Settings home" *relocation* of auth/URL into +> a separate route + compact chip — pure navigation reorg, the diagnosis/QR value +> shipped in-place; (b) Track 2 SM **token** in the QR — the SM token is a +> server-side secret not exposed to the browser; minting it into a QR is a security +> decision left to the user. The QR carries `backendUrl` + `serviceManagerUrl`; the +> backend-proxy transport works token-free whenever the backend is up. + +This is the **authoritative, consolidated** plan. It merges two prior efforts: + +- `RECONNECT_FIXES_PLAN.md` — the **mechanical reconnection** layer (8 gaps: never + permanently give up, never destroy saved state on one failure, detect zombie sockets, + recover on foreground/relaunch). Spans mobile app + wearable client + HAVPE relay. +- `MOBILE_APP_CONNECTION_UX_PLAN.md` — the **auth + diagnosis + network-control** layer + (silent token refresh so "logged in means logged in", honest actionable failures / + Connection Doctor, QR-first config, service-manager control). App-only (+ one webui change). + +Both originals are now superseded by this file and may be deleted once this is approved. + +--- + +## Why a merge (the collision) + +The two plans operate at different layers but **rewrite the same app code** — the +token-refresh / reconnect path in `app/src/hooks/useAudioStreamer.ts` (`attemptReLogin`, +`attemptReconnect`, `onclose`, NetInfo). + +- The UX plan's **Phase 1 central auth manager** is the natural *home* for re-login. +- The reconnect plan's **A2/A3** call re-login *during* a reconnect. + +If we land the mechanical reconnect first against today's inline `attemptReLogin`, Phase 1 +then tears it out — pure churn. So the merged order is: + +> **Build the central auth manager first**, then layer WS-reconnect on top of it. Anything +> that does *not* touch auth — BLE device-forget (A1), the backend `pong` reply, and the +> entire device-client track (B/C) — is independent and can proceed in parallel. + +Guiding principles (carried from both plans): +- **Persistent retry** — never permanently give up; never delete saved state on a single + failure; always keep a recovery path alive. +- **Set-once, then invisible** — configure backend URL + creds once (ideally by QR); if the + app says you're connected, actions work; failures are honest and actionable. + +--- + +## Tracks (what can run in parallel) + +| Track | Content | Depends on | Parallelizable? | +|-------|---------|-----------|-----------------| +| **0 · Backend pong** | `websocket_controller.py` reply `{type:'pong'}` (2 sites) | nothing | ✅ anytime | +| **1 · App auth core** | Auth manager → mechanical reconnect (A1–A4) → Connection Doctor | Track 0 for A2 | ⛓ internally ordered | +| **2 · App network control** | QR bundle → SM client → Network Overview → remote start | Track 1 auth manager | after Track 1 | +| **3 · Device clients** | Wearable (B1/B2/B3) + HAVPE relay (C1/C2) reconnect | Track 0 (uses pong) optional | ✅ fully independent of app | + +Track 3 has **no app dependency** and can be implemented start-to-finish alongside Track 1. + +--- + +## Phase 0 — Backend `pong` reply *(standalone, unblocks A2)* + +**File:** `backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py` +(`:1719-1721` and the second ping site `:1772`) + +Today the backend receives app-level `{type:'ping'}` and just logs it — no reply, so the +client can't tell a half-open socket from a healthy idle one. + +**Fix:** at *both* ping sites, reply `{type:'pong'}` over the same socket (mirror how other +control replies are sent). No client behavior depends on it until A2 ships, so this is safe +to land immediately. + +**Verify:** `cd tests && make test-quick` (WS control-message regression) + a manual +`wscat` ping to `/ws` confirming the `{type:"pong"}` reply. + +--- + +## Track 1 — App connection core (ordered) + +### 1A. BLE device-forget on a single launch-reconnect failure *(independent — can land first)* +**File:** `app/src/hooks/useAutoReconnect.ts:183-198` +**Problem:** the launch auto-reconnect `catch` (`:189-192`) calls +`saveLastConnectedDeviceId(null)` + `setLastKnownDeviceId(null)` — one failed attempt +(device briefly out of range at startup) permanently forgets the device. The continuous +backoff retry loop (`:90-168`) only fires on a `connected→disconnected` transition, so it +never covers a launch attempt that never connected. + +**Fix:** +- Extract the disconnect-retry machinery (`:103-167`) into a + `scheduleRetry(deviceId, isQuickFailure)` callback (backoff refs, countdown timer, + `connectToDevice` call). +- In the launch `catch`, **stop wiping the device**; call `scheduleRetry(lastKnownDeviceId, true)`. + The device id survives; retries continue with capped backoff (10s→5min). +- `handleCancelAutoReconnect` (`:207-216`) and the explicit Disconnect button + (`index.tsx:373-377,391-396`) remain the *only* paths that clear the saved device. + +> Pure BLE, no auth dependency. This is the highest-value mechanical fix and can ship before +> the auth manager. (This was the in-flight edit; re-do it cleanly here.) + +### 1B. Central auth / connection manager *(foundation for everything below)* +Fixes UX issues A1–A3 (creds disappear on logout, forced relogin while "logged in", +plaintext password). + +- **New** `app/src/contexts/AuthContext.tsx` (or `services/auth.ts`): single source of truth + for `{ backendUrl, email, token, status }`. +- **Silent refresh everywhere:** lift `useAudioStreamer.ts::attemptReLogin` into the manager + as a `fetchAuthed()` wrapper that retries once on 401/expiry using stored creds, then + surfaces a real failure only if re-login fails. *All* calls (health, future overview) go + through it. JWTs are 1h, so this is what makes "logged in" actually mean logged in. +- **Honest status:** "Logged in as X" reflects token validity / refreshing, not mere presence. +- **Split logout from forget:** `clearAuthData()` → + - **Log out** = clear token only; keep email (+ optionally password) → re-login is one tap. + - **Forget account** = clear everything (today's behavior, made explicit). +- **SecureStore:** move password (+ token) to `expo-secure-store`; keep email in AsyncStorage. + +**Touchpoints:** `app/src/utils/storage.ts` (SecureStore + split clear fns), +`app/src/components/AuthSection.tsx` (consume manager; logout vs forget), +`app/src/hooks/useAudioStreamer.ts` (relocate `attemptReLogin`). + +### 1C. Persistent WS retry — remove permanent give-up *(A3; consumes 1B)* +**File:** `app/src/hooks/useAudioStreamer.ts:255-262` +**Problem:** after 10 attempts it sets `manuallyStoppedRef = true`, which also disables the +NetInfo recovery path (`:455`) and the AppState path (1E). It should keep trying. + +**Fix:** +- Remove `manuallyStoppedRef.current = true` on exhaustion. Keep backoff **capped** at + `MAX_RECONNECT_MS` (30s); attempts continue indefinitely while a session is intended. +- After N attempts, downgrade to a low-frequency keep-trying state and post + "Connection lost — still retrying" **once** (guard with a ref) instead of giving up. +- Re-login on reconnect now goes through the **1B auth manager**, not inline. +- `manuallyStoppedRef` is set only by `stopStreaming()` (`:219`) — genuine user stop. + +### 1D. Zombie WebSocket detection *(A2; client side; backend done in Phase 0)* +**File:** `app/src/hooks/useAudioStreamer.ts:326-334` (+ `onmessage` `:348-370`) +**Problem:** the 25s heartbeat is fire-and-forget; a silently dead TCP keeps +`readyState === OPEN` so nothing reconnects until NetInfo happens to fire. + +**Fix (client):** add `lastPongRef`; stamp it on `{type:'pong'}` in `ws.onmessage`; in the +heartbeat interval, if `now - lastPongRef > 2×HEARTBEAT_MS`, `ws.close()` (→ `onclose` → +existing `attemptReconnect`). Reset `lastPongRef` on `onopen`. (Backend reply = Phase 0.) + +### 1E. App-lifecycle (foreground / relaunch) reconnect *(A4)* +**Files:** `app/src/hooks/useAudioStreamer.ts` (WS) + `app/src/hooks/useAutoReconnect.ts` (BLE) +**Problem:** no `AppState` listener anywhere. On iOS the JS runtime suspends in background; +on resume nothing re-establishes the socket. (True iOS *background streaming* needs +background modes and is **out of scope** — we handle *resume on foreground* + warm relaunch.) + +**Fix:** +- **WS:** `AppState` listener mirroring the NetInfo effect (`:452-465`): on `'active'`, if + `currentUrlRef` set, not manually stopped, and `readyState !== OPEN` → `attemptReconnect()`. +- **BLE:** `AppState` listener: on `'active'`, if a `lastKnownDeviceId` exists and isn't + connected, reset `triedAutoReconnectForCurrentId=false` so the launch-reconnect effect + (`:171-205`) re-fires. +- `onDeviceConnect`'s existing audio-pipeline auto-restart (`index.tsx:87-97`) means BLE + recovery transitively resumes streaming. + +### 1F. Connection Doctor + Settings home *(UX C1, C2, A4-UX; consumes 1B)* +**File:** `app/src/components/BackendStatus.tsx` (today: single `fetch(${baseUrl}/health)`) +**Problem:** any failure collapses to a bare "Network request failed" — no diagnosis, no +recovery path (this is the originating incident: backend reachable by browser/curl, app +just says "network failed"). + +**Fix:** +- Replace the single `/health` fetch with a **probe ladder** → classified, actionable + messages: *offline* → "You're offline"; *tailnet unreachable* → **"Can't reach your + tailnet — is Tailscale connected?"** [Open Tailscale] [Scan QR]; *backend down* → + **"Backend is down"** [Start backend] (Track 2); *ok* → connected. +- **QR scan reachable in every state** (un-gate it). Manual URL typing = last resort. +- Move backend URL + auth into a dedicated **Settings / Connection** screen; collapse the + main surface to a compact status chip when healthy. Set-once, not constantly present. +- (Optional) weak native VPN-interface hint (`utun*`/`tun*`) as a secondary signal — "a VPN + is up", not "Tailscale specifically" (no official API exists; documented limitation). + +--- + +## Track 2 — App network control (after Track 1 auth manager) + +### 2A. Richer QR bundle *(UX Phase 3; web + app)* +- `backends/advanced/webui/src/pages/System.tsx`: QR payload becomes JSON + `{ backendUrl, serviceManagerUrl, smToken }` (SM token from the System page's existing SM + config). Keep the copyable plain URL for fallback. +- `app/src/components/QRScanner.tsx` + `app/src/utils/urlConversion.ts`: parse **both** a + plain URL (legacy) and the JSON bundle; persist SM URL + token (token in SecureStore). + +### 2B. Service-manager client + Network Overview *(UX Phase 4, fixes C3)* +**New** `app/src/services/serviceManager.ts` with **two auto-selected transports**: +- **via backend proxy** `/api/admin/services` (existing JWT) when backend is up — no SM token; +- **direct to SM** (`/node`, `/cluster`, `/services`) with stored token when backend is down; +- SM URL auto-derived from backend host `:8775`, confirmed via the **unauthed** `/health`. + +**Network Overview screen:** status table of nodes/services (backend, ASR, speaker, +wakeword, …) from `/cluster` + `/services`, each with running/health. + +> Architectural note: the SM solves *"backend down"*, **not** *"Tailscale down"* (reaching +> the SM still needs the tailnet — that case is handled by 1F's guidance). + +### 2C. Remote start / restart *(UX Phase 5, fixes C4)* +In the overview, a down backend shows **Start** → direct `POST /services/backend/start` to +the SM (the only path that works when the backend is down) → poll `GET /operations/{id}` → +reflect progress. Same pattern restarts any service. + +--- + +## Track 3 — Device clients (independent of the app) + +### 3A. Wearable client — backend-WS reconnect + mid-session JWT refresh *(B1/B2)* +**Files:** `extras/local-wearable-client/backend_sender.py:212-296`, `main.py:245-256` +**Problem:** `stream_to_backend` opens the WS once; on WS error the wrapper just logs +(`main.py:255-256`) and the backend stays dead until the whole BLE session cycles. No +mid-session token refresh. + +**Fix:** +- Wrap the `websockets.connect` block (`backend_sender.py:234-296`) in a capped + exponential-backoff reconnect loop. On a WS drop while the audio generator is still + producing, re-fetch the JWT (`get_jwt_token`, `:217` — this **is** the mid-session refresh, + solving B2) and re-dial **without ending the BLE session**. +- Drain-vs-reconnect: the queue-backed generator (`main.py:246-251`) yields `None` only at + true session end → treat `None` as "stop", WS exceptions as "reconnect". Drop audio during + the gap (matches current outage behavior; document the choice). +- Reset backoff after a healthy connection duration (mirror app's `MIN_HEALTHY_DURATION`). + +### 3B. (optional) headless `run()` BLE backoff parity *(B3, low priority)* +**File:** `extras/local-wearable-client/main.py:584-621` — headless path rescans at a fixed +`scan_interval` with no backoff. The launchd default is the menu app (`menu_app.py:468-488`, +already has backoff), so add the same exponential backoff for parity only if cheap. + +### 3C. HAVPE relay — backend-WS reconnect *(C1)* +**File:** `extras/havpe-relay/relay_core.py:300-376` (`run_device_session`) +**Problem:** `websockets.connect` (`:301`) is opened once; any WS close tears down the +session (`:364-376`) and waits passively for the ESP32 to re-dial. +**Fix:** wrap WS-connect + task group (`:301-360`) in an inner capped-backoff retry loop, +keeping the device `reader`/`writer` alive across WS reconnects. Re-fetch the token +(`get_jwt_token`, `:282`) on each re-dial. Break only on device disconnect +(`IncompleteReadError`/reader EOF) or idle timeout — not on a WS blip. + +### 3D. HAVPE relay — ESPHome API reconnect within a session *(C2, lower priority)* +**Files:** `extras/havpe-relay/relay_core.py:304-321`, `device_controller.py:33-44` +**Problem:** one API connect timeout → audio-only for the entire session; no reconnect. +**Fix:** use `aioesphomeapi`'s `ReconnectLogic` (or a lightweight periodic re-`connect()`) +so button/dial/LED/speaker recover mid-session. + +> Note: device→relay audio TCP is server-side (relay can't initiate); the firmware re-dials +> and the idle-timeout (`relay_core.py:45`) reaps zombies. No change needed there. + +--- + +## Suggested implementation order + +1. **Phase 0** backend `pong` — tiny, standalone, unblocks 1D. +2. **1A** BLE device-forget — highest mechanical value, no auth dependency. +3. **1B** central auth manager — foundation; removes daily auth pain (A1–A3 UX). +4. **1C → 1D → 1E** mechanical WS reconnect on top of the auth manager. +5. **1F** Connection Doctor + Settings home — fixes the originating "useless message" incident. +6. **2A → 2B → 2C** network-control story, each building on the last. +- **Track 3 (3A, 3C, then 3D/3B)** in parallel throughout — no app dependency. + +Each item is independently shippable. + +--- + +## Verification + +**App mechanical (1A,1C,1D,1E):** dev client on a physical phone (BLE needs hardware): +- 1A: connect device, force-quit, walk out of range, relaunch in range → reconnects and is + **not** forgotten if the first attempt fails (`lastKnownDeviceId` persists). +- 1C: backend down for >10 reconnect attempts, then back → client resumes without a manual + restart (previously gave up permanently). +- 1D: start streaming, kill the backend's TCP path abruptly (drop wifi / `kill -STOP`) so no + close frame → within ~50s client detects missed pongs, closes, reconnects on return. +- 1E: background the app (iOS), foreground → WS re-establishes; toggle BLE off/on while + backgrounded then foreground → device reconnects. + +**App auth/UX (1B,1F):** log out → email retained, one-tap re-login; let a token expire (1h +or shortened TTL) and hit a non-streaming action → silent refresh, no forced relogin; point +the app at an unreachable tailnet → Connection Doctor says *tailnet unreachable* with +actions, not "Network request failed". + +**Network control (2A–2C):** scan the JSON QR → backend URL + SM URL + token persisted; take +the backend down → Overview shows it down with **Start** → SM starts it → status flips healthy. + +**Device clients (3A,3C):** `cd extras/local-wearable-client && uv run python main.py run` +against a local backend; mid-stream `./restart.sh` → client re-dials and resumes without +dropping BLE; run >1h (or shorten token TTL) to confirm mid-session re-auth. HAVPE: ESP32 +dialed in, restart backend mid-session → relay re-dials, ESP32 stays connected; kill/restore +the ESPHome API → button/LED recover (3D). + +**Regression:** `cd tests && make test-quick` after the Phase 0 `pong` change. diff --git a/Docs/audio-pipeline-architecture.md b/Docs/audio-pipeline-architecture.md deleted file mode 100644 index afba52db..00000000 --- a/Docs/audio-pipeline-architecture.md +++ /dev/null @@ -1,1241 +0,0 @@ -# Audio Pipeline Architecture - -This document explains how audio flows through the Chronicle system from initial capture to final storage, including all intermediate processing stages, Redis streams, and data storage locations. - -## Table of Contents - -- [Overview](#overview) -- [Architecture Diagram](#architecture-diagram) -- [Data Sources](#data-sources) -- [Redis Streams: The Central Pipeline](#redis-streams-the-central-pipeline) -- [Producer: AudioStreamProducer](#producer-audiostreamproducer) -- [Dual-Consumer Architecture](#dual-consumer-architecture) -- [Transcription Results Aggregator](#transcription-results-aggregator) -- [Job Queue Orchestration (RQ)](#job-queue-orchestration-rq) -- [Data Storage](#data-storage) -- [Complete End-to-End Flow](#complete-end-to-end-flow) -- [Key Design Patterns](#key-design-patterns) -- [Failure Handling](#failure-handling) - -## Overview - -Chronicle's audio pipeline is built on three core technologies: - -- **Redis Streams**: Distributed message queues for audio chunks and transcription results -- **Background Tasks**: Async consumers that process streams independently -- **RQ Job Queue**: Orchestrates session-level and conversation-level workflows - -**Key Insight**: Multiple workers can independently consume the **same audio stream** using Redis Consumer Groups, enabling parallel processing paths (transcription + disk persistence) without duplication. - -## Architecture Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ AUDIO INPUT │ -│ WebSocket (/ws) │ File Upload (/audio/upload) │ Google Drive │ -└────────────────────────────────┬────────────────────────────────┘ - ↓ - ┌────────────────────────┐ - │ AudioStreamProducer │ - │ - Chunk audio (0.25s) │ - │ - Session metadata │ - └────────────┬───────────┘ - ↓ - ┌────────────────────────────────┐ - │ Redis Stream (Per Client) │ - │ audio:stream:{client_id} │ - └─────┬──────────────────┬───────┘ - ↓ ↓ - ┌───────────────────────┐ ┌──────────────────────┐ - │ Transcription Consumer│ │ Audio Persistence │ - │ Group (streaming/batch)│ │ Consumer Group │ - │ │ │ │ - │ → Deepgram WebSocket │ │ → Writes WAV files │ - │ → Batch buffering │ │ → Monitors rotation │ - │ → Publish results │ │ → Stores file paths │ - └───────────┬───────────┘ └──────────┬───────────┘ - ↓ ↓ - ┌───────────────────────┐ ┌──────────────────────┐ - │ transcription:results │ │ Disk Storage │ - │ :{session_id} │ │ data/chunks/*.wav │ - └───────────┬───────────┘ └──────────────────────┘ - ↓ - ┌───────────────────────┐ - │ TranscriptionResults │ - │ Aggregator │ - │ - Combines chunks │ - │ - Merges timestamps │ - └───────────┬───────────┘ - ↓ - ┌───────────────────────┐ - │ RQ Job Pipeline │ - ├───────────────────────┤ - │ speech_detection_job │ ← Session-level - │ ↓ │ - │ open_conversation_job │ ← Conversation-level - │ ↓ │ - │ Post-Conversation: │ - │ • transcribe_full │ - │ • speaker_recognition │ - │ • memory_extraction │ - │ • title_generation │ - └───────────┬───────────┘ - ↓ - ┌───────────────────────┐ - │ Final Storage │ - ├───────────────────────┤ - │ MongoDB: conversations│ - │ Disk: WAV files │ - │ Qdrant: Memories │ - └───────────────────────┘ -``` - -## Data Sources - -### 1. WebSocket Streaming (`/ws`) - -**Endpoint**: `/ws?codec=pcm|opus&token=xxx&device_name=xxx` - -**Handlers**: -- `handle_pcm_websocket()` - Raw PCM audio -- `handle_omi_websocket()` - Opus-encoded audio (compressed, used by OMI devices) - -**Protocol**: Wyoming Protocol (JSON lines + binary frames) - -**Authentication**: JWT token required - -**Location**: `backends/advanced/src/advanced_omi_backend/routers/websocket_routes.py` - -**Container**: `chronicle-backend` - -### 2. File Upload (`/audio/upload`) - -**Endpoint**: `POST /api/audio/upload` - -**Accepts**: Multiple WAV files (multipart form data) - -**Authentication**: Admin only - -**Device ID**: Auto-generated as `{user_id_suffix}-upload` or custom `device_name` - -**Location**: `backends/advanced/src/advanced_omi_backend/routers/api_router.py` - -**Container**: `chronicle-backend` - -### 3. Google Drive Upload - -**Endpoint**: `POST /api/audio/upload_audio_from_gdrive` - -**Source**: Google Drive folder ID - -**Processing**: Downloads files and enqueues for processing - -**Container**: `chronicle-backend` - -## Redis Streams: The Central Pipeline - -### Stream Naming Convention - -``` -audio:stream:{client_id} -``` - -**Examples**: -- `audio:stream:user01-phone` -- `audio:stream:user01-omi-device` -- `audio:stream:user01-upload` - -**Characteristics**: -- **Client-specific isolation**: Each device has its own stream -- **Fan-out pattern**: Multiple consumer groups read the same stream -- **MAXLEN constraint**: Keeps last 25,000 entries (auto-trimming) -- **No TTL**: Streams persist until manually deleted -- **Container**: `redis` service - -### Session Metadata Storage - -``` -audio:session:{session_id} -``` - -**Type**: Redis Hash - -**Fields**: -- `user_id`: MongoDB ObjectId -- `client_id`: Device identifier -- `connection_id`: WebSocket connection ID -- `stream_name`: `audio:stream:{client_id}` -- `status`: `"active"` → `"finalizing"` → `"complete"` -- `chunks_published`: Integer count -- `speech_detection_job_id`: RQ job ID -- `audio_persistence_job_id`: RQ job ID -- `websocket_connected`: `true|false` -- `transcription_error`: Error message (if any) - -**TTL**: 1 hour - -**Container**: `redis` - -### Transcription Results Stream - -``` -transcription:results:{session_id} -``` - -**Type**: Redis Stream - -**Written by**: Transcription consumers (streaming or batch) - -**Read by**: `TranscriptionResultsAggregator` - -**Message Fields**: -- `text`: Transcribed text for this chunk -- `chunk_id`: Redis message ID from audio stream -- `provider`: `"deepgram"` or `"parakeet"` -- `confidence`: Float (0.0-1.0) -- `words`: JSON array of word-level timestamps -- `segments`: JSON array of speaker segments - -**Lifecycle**: Deleted when conversation completes - -**Container**: `redis` - -### Conversation Tracking - -``` -conversation:current:{session_id} -``` - -**Type**: Redis String - -**Value**: Current `conversation_id` (UUID) - -**Purpose**: Signals audio persistence job to rotate WAV file - -**TTL**: 24 hours - -**Container**: `redis` - -### Audio File Path Mapping - -``` -audio:file:{conversation_id} -``` - -**Type**: Redis String - -**Value**: File path (e.g., `1704067200000_user01-phone_convid.wav`) - -**Purpose**: Links conversation to its audio file on disk - -**TTL**: 24 hours - -**Container**: `redis` - -## Producer: AudioStreamProducer - -**File**: `backends/advanced/src/advanced_omi_backend/services/audio_stream/producer.py` - -**Container**: `chronicle-backend` (in-memory, no persistence) - -### Responsibilities - -#### 1. Session Initialization - -```python -async def init_session( - session_id: str, - user_id: str, - client_id: str, - provider: str, - mode: str -) -> None -``` - -**Actions**: -- Creates `audio:session:{session_id}` hash in Redis -- Initializes in-memory buffer for chunking -- Stores session metadata (user, client, provider) - -#### 2. Audio Chunking - -```python -async def add_audio_chunk( - session_id: str, - audio_data: bytes -) -> list[str] -``` - -**Process**: -1. Buffers incoming audio (arbitrary size from WebSocket) -2. Creates **fixed-size chunks**: 0.25 seconds = 8,000 bytes - - Assumes: 16kHz sample rate, 16-bit mono PCM -3. Prevents cutting audio mid-word (aligned chunks) -4. Publishes each chunk to `audio:stream:{client_id}` via `XADD` -5. Returns Redis message IDs for tracking - -**In-Memory Storage**: Session buffers stored in `AudioStreamProducer._session_buffers` dict - -#### 3. Session End Signal - -```python -async def send_session_end_signal(session_id: str) -> None -``` - -**Actions**: -- Publishes special `{"type": "END"}` message to stream -- Signals all consumers to flush buffers and finalize -- Updates session status to `"finalizing"` - -### Data Location - -**Memory**: `chronicle-backend` container (in-memory buffers) - -**Redis**: Published chunks in `audio:stream:{client_id}` (redis container) - -## Dual-Consumer Architecture - -Chronicle uses **Redis Consumer Groups** to enable multiple independent consumers to read the **same audio stream** without message duplication. - -### Consumer Group 1: Transcription - -Two implementations available: - -#### A. Streaming Transcription Consumer - -**File**: `backends/advanced/src/advanced_omi_backend/services/transcription/streaming_consumer.py` - -**Class**: `StreamingTranscriptionConsumer` - -**Consumer Group**: `streaming-transcription` - -**Provider**: Deepgram (WebSocket-based) - -**Process**: -1. Discovers `audio:stream:*` streams dynamically using `SCAN` -2. Opens persistent WebSocket connection to Deepgram per stream -3. Sends audio chunks **immediately** (no buffering) -4. Publishes **interim results** to `transcription:interim:{session_id}` (Redis Pub/Sub) -5. Publishes **final results** to `transcription:results:{session_id}` (Redis Stream) -6. Triggers plugins on final results only -7. ACKs messages with `XACK` to prevent reprocessing -8. Handles END signal: closes WebSocket, cleans up - -**Container**: `chronicle-backend` (Background Task via `BackgroundTaskManager`) - -**Real-time Updates**: Interim results pushed to WebSocket clients via Pub/Sub - -#### B. Batch Transcription Consumer - -**File**: `backends/advanced/src/advanced_omi_backend/services/audio_stream/consumer.py` - -**Class**: `BaseAudioStreamConsumer` - -**Consumer Group**: `{provider_name}_workers` (e.g., `deepgram_workers`, `parakeet_workers`) - -**Providers**: Deepgram (batch), Parakeet ASR (offline) - -**Process**: -1. Reads from `audio:stream:{client_id}` using `XREADGROUP` -2. Buffers chunks per session (default: 30 chunks = ~7.5 seconds) -3. When buffer full: - - Combines chunks into single audio buffer - - Transcribes using provider API - - Adjusts word/segment timestamps relative to session start - - Publishes result to `transcription:results:{session_id}` -4. Flushes remaining buffer on END signal -5. ACKs all buffered messages with `XACK` -6. Trims stream to keep only last 1,000 entries (`XTRIM MAXLEN`) - -**Container**: `chronicle-backend` (Background Task) - -**Batching Benefits**: Reduces API calls, improves transcription accuracy (more context) - -### Consumer Group 2: Audio Persistence - -**File**: `backends/advanced/src/advanced_omi_backend/workers/audio_jobs.py` - -**Function**: `audio_streaming_persistence_job()` - -**Consumer Group**: `audio_persistence` - -**Consumer Name**: `persistence-worker-{session_id}` - -**Process**: -1. Reads audio chunks from `audio:stream:{client_id}` using `XREADGROUP` -2. Monitors `conversation:current:{session_id}` for rotation signals -3. On conversation rotation: - - Closes current WAV file - - Opens new WAV file with new conversation ID -4. Writes chunks immediately to disk (real-time persistence) -5. Stores file path in `audio:file:{conversation_id}` (Redis) -6. Handles END signal: closes file, returns statistics -7. ACKs messages after writing to disk - -**Container**: `chronicle-backend` (RQ Worker) - -**Output Location**: `backends/advanced/data/chunks/` (volume-mounted) - -**File Format**: `{timestamp_ms}_{client_id}_{conversation_id}.wav` - -### Fan-Out Pattern Visualization - -``` -audio:stream:user01-phone - ↓ - ├─ Consumer Group: "streaming-transcription" - │ └─ Worker: streaming-worker-12345 - │ → Reads: chunks → Deepgram WS → Results stream - │ - ├─ Consumer Group: "deepgram_workers" - │ ├─ Worker: deepgram-worker-67890 - │ ├─ Worker: deepgram-worker-67891 - │ └─ Reads: chunks → Buffer (30) → Batch API → Results stream - │ - └─ Consumer Group: "audio_persistence" - └─ Worker: persistence-worker-sessionXYZ - → Reads: chunks → WAV file (disk) -``` - -**Key Benefits**: -- **Horizontal scaling**: Multiple workers per group -- **Independent processing**: Each group processes all messages -- **No message loss**: Messages ACKed only after processing -- **Decoupled**: Producer doesn't know about consumers - -## Transcription Results Aggregator - -**File**: `backends/advanced/src/advanced_omi_backend/services/audio_stream/aggregator.py` - -**Class**: `TranscriptionResultsAggregator` - -**Container**: `chronicle-backend` (in-memory, stateless) - -### Methods - -#### Get Combined Results - -```python -async def get_combined_results(session_id: str) -> dict -``` - -**Returns**: -```python -{ - "text": "Full transcript...", - "segments": [SpeakerSegment, ...], - "words": [Word, ...], - "provider": "deepgram", - "chunk_count": 42 -} -``` - -**Process**: -- Reads all entries from `transcription:results:{session_id}` -- For **streaming mode**: Uses latest final result only (supersedes interim) -- For **batch mode**: Combines all chunks sequentially -- Adjusts timestamps across chunks (adds audio offset) -- Merges speaker segments, words - -#### Get Session Results (Raw) - -```python -async def get_session_results(session_id: str) -> list[dict] -``` - -**Returns**: Raw list of transcription result messages - -#### Get Real-time Results - -```python -async def get_realtime_results( - session_id: str, - last_id: str = "0-0" -) -> tuple[list[dict], str] -``` - -**Returns**: `(new_results, new_last_id)` - -**Purpose**: Incremental polling for live UI updates - -### Data Location - -**Input**: `transcription:results:{session_id}` stream (redis container) - -**Processing**: In-memory (chronicle-backend container) - -**Output**: Returned to caller (no persistence) - -## Job Queue Orchestration (RQ) - -**Library**: Python RQ (Redis Queue) - -**File**: `backends/advanced/src/advanced_omi_backend/controllers/queue_controller.py` - -**Containers**: -- `chronicle-backend` (enqueues jobs) -- `rq-worker` (executes jobs) - -### Job Pipeline - -``` -Session Starts - ↓ -┌─────────────────────────────────┐ -│ stream_speech_detection_job │ ← Session-level (long-running) -│ - Polls transcription results │ -│ - Analyzes speech content │ -│ - Checks speaker filters │ -└─────────────┬───────────────────┘ - ↓ (when speech detected) -┌─────────────────────────────────┐ -│ open_conversation_job │ ← Conversation-level (long-running) -│ - Creates conversation │ -│ - Signals file rotation │ -│ - Monitors activity │ -│ - Detects end conditions │ -└─────────────┬───────────────────┘ - ↓ (when conversation ends) -┌─────────────────────────────────┐ -│ Post-Conversation Pipeline │ -├─────────────────────────────────┤ -│ • recognize_speakers_job │ -│ • memory_extraction_job │ -│ • generate_title_summary_job │ -│ • dispatch_conversation_complete│ -└─────────────────────────────────┘ -``` - -### Session-Level Jobs - -#### Speech Detection Job - -**File**: `backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py` - -**Function**: `stream_speech_detection_job()` - -**Scope**: Entire session (can handle multiple conversations) - -**Max Duration**: 24 hours - -**Process**: -1. Polls `TranscriptionResultsAggregator.get_combined_results()` (1-second intervals) -2. Analyzes speech content: - - Word count > 10 - - Duration > 5 seconds - - Confidence > threshold -3. If speaker filter enabled: checks for enrolled speakers -4. When speech detected: - - Creates conversation in MongoDB - - Enqueues `open_conversation_job` - - **Exits** (restarts when conversation completes) -5. Handles transcription errors (marks session with error flag) - -**RQ Queue**: `speech_detection_queue` (dedicated queue) - -**Container**: `rq-worker` - -### Conversation-Level Jobs - -#### Open Conversation Job - -**File**: `backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py` - -**Function**: `open_conversation_job()` - -**Scope**: Single conversation - -**Max Duration**: 3 hours - -**Process**: -1. Creates conversation document in MongoDB `conversations` collection -2. Sets `conversation:current:{session_id}` = `conversation_id` (Redis) - - **Triggers audio persistence job to rotate WAV file** -3. Polls for transcription updates (1-second intervals) -4. Tracks speech activity (inactivity timeout = 60 seconds default) -5. Detects end conditions: - - WebSocket disconnect - - User manual stop - - Inactivity timeout -6. Waits for audio file path from persistence job -7. Saves `audio_path` to conversation document -8. Triggers conversation-level plugins -9. Enqueues post-conversation jobs -10. Calls `handle_end_of_conversation()` for cleanup + restart - -**RQ Queue**: `default` - -**Container**: `rq-worker` - -#### Audio Persistence Job - -**File**: `backends/advanced/src/advanced_omi_backend/workers/audio_jobs.py` - -**Function**: `audio_streaming_persistence_job()` - -**Scope**: Entire session (parallel with open_conversation_job) - -**Max Duration**: 24 hours - -**Process**: -1. Monitors `conversation:current:{session_id}` for rotation signals -2. For each conversation: - - Opens new WAV file: `{timestamp}_{client_id}_{conversation_id}.wav` - - Writes chunks immediately as they arrive from stream - - Stores file path in `audio:file:{conversation_id}` -3. On rotation signal: - - Closes current file - - Opens new file for next conversation -4. On END signal: - - Closes file - - Returns statistics (chunk count, bytes, duration) - -**Output**: WAV files in `backends/advanced/data/chunks/` - -**Container**: `rq-worker` - -### Post-Conversation Pipeline - -**Streaming conversations**: Use streaming transcript saved during conversation. No batch re-transcription. - -**File uploads**: Batch transcription job runs first, then post-conversation jobs depend on it. - -#### 1. Recognize Speakers Job - -**File**: `backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py` - -**Function**: `recognize_speakers_job()` - -**Process**: -- Sends audio + segments to speaker recognition service -- Identifies speakers using voice embeddings -- Updates segment speaker labels in MongoDB - -**Optional**: Only runs if `DISABLE_SPEAKER_RECOGNITION=false` - -**Container**: `rq-worker` - -**External Service**: `speaker-recognition` container (if enabled) - -#### 2. Memory Extraction Job - -**File**: `backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py` - -**Function**: `memory_extraction_job()` - -**Prerequisite**: Speaker recognition job - -**Process**: -- Uses LLM (OpenAI/Ollama) to extract semantic facts -- Stores embeddings in vector database: - - **Chronicle provider**: Qdrant - - **OpenMemory MCP provider**: External OpenMemory server - -**Container**: `rq-worker` - -**External Services**: -- `ollama` or OpenAI API (LLM) -- `qdrant` or OpenMemory MCP (vector storage) - -#### 3. Generate Title Summary Job - -**File**: `backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py` - -**Function**: `generate_title_summary_job()` - -**Prerequisite**: Speaker recognition job - -**Process**: -- Uses LLM to generate title, summary, detailed summary -- Updates conversation document in MongoDB - -**Container**: `rq-worker` - -#### 4. Dispatch Conversation Complete Event - -**File**: `backends/advanced/src/advanced_omi_backend/workers/conversation_jobs.py` - -**Function**: `dispatch_conversation_complete_event_job()` - -**Process**: -- Triggers `conversation.complete` plugin event - -**Container**: `rq-worker` - -#### Batch Transcription Job - -**File**: `backends/advanced/src/advanced_omi_backend/workers/transcription_jobs.py` - -**Function**: `transcribe_full_audio_job()` - -**When used**: -- File uploads via `/api/process-audio-files` -- Manual reprocessing via `/api/conversations/{id}/reprocess-transcript` -- NOT used for streaming conversations - -**Process**: -- Reconstructs audio from MongoDB chunks -- Batch transcribes entire audio -- Stores transcript with word-level timestamps - -**Container**: `rq-worker` - -### Session Restart - -**File**: `backends/advanced/src/advanced_omi_backend/utils/conversation_utils.py` - -**Function**: `handle_end_of_conversation()` - -**Process**: -1. Deletes transcription results stream: `transcription:results:{session_id}` -2. Increments `session:conversation_count:{session_id}` -3. Checks if session still active (WebSocket connected) -4. If active: Re-enqueues `stream_speech_detection_job` for next conversation -5. Cleans up consumer groups and pending messages - -**Purpose**: Allows continuous recording with multiple conversations per session - -## Data Storage - -### MongoDB Collections - -**Database**: `chronicle` - -**Container**: `mongo` - -**Volume**: `mongodb_data` (persistent) - -#### `conversations` Collection - -**Schema**: -```python -{ - "_id": ObjectId, - "conversation_id": "uuid-string", - "audio_uuid": "session_id", - "user_id": ObjectId, - "client_id": "user01-phone", - - # Content - "title": "Meeting notes", - "summary": "Discussion about...", - "detailed_summary": "Longer summary...", - "transcript": "Full transcript text", - "audio_path": "1704067200000_user01-phone_convid.wav", - - # Versioned Transcripts - "active_transcript_version": "v1", - "transcript_versions": { - "v1": { - "text": "Full transcript", - "segments": [SpeakerSegment], - "words": [Word], - "provider": "deepgram", - "processing_time_seconds": 45.2, - "created_at": "2025-01-11T12:00:00Z" - } - }, - "segments": [SpeakerSegment], # From active version - - # Metadata - "created_at": "2025-01-11T12:00:00Z", - "completed_at": "2025-01-11T12:15:00Z", - "end_reason": "user_stopped|inactivity_timeout|websocket_disconnect", - "deleted": false -} -``` - -**Indexes**: -- `user_id` (for user-scoped queries) -- `client_id` (for device filtering) -- `conversation_id` (unique) - -#### `audio_chunks` Collection - -**Purpose**: Stores raw audio session data - -**Schema**: -```python -{ - "_id": ObjectId, - "audio_uuid": "session_id", - "user_id": ObjectId, - "client_id": "user01-phone", - "created_at": "2025-01-11T12:00:00Z", - "metadata": { ... } -} -``` - -**Use Case**: Speech-driven architecture (sessions without conversations) - -#### `users` Collection - -**Purpose**: User accounts, authentication, preferences - -**Schema**: -```python -{ - "_id": ObjectId, - "email": "user@example.com", - "hashed_password": "...", - "is_active": true, - "is_superuser": false, - "created_at": "2025-01-11T12:00:00Z" -} -``` - -### Disk Storage - -**Location**: `backends/advanced/data/chunks/` - -**Container**: `chronicle-backend` (volume-mounted) - -**Volume**: `./backends/advanced/data/chunks:/app/data/chunks` - -**File Format**: WAV files - -**Naming Convention**: `{timestamp_ms}_{client_id}_{conversation_id}.wav` - -**Example**: `1704067200000_user01-phone_550e8400-e29b-41d4-a716-446655440000.wav` - -**Created by**: `audio_streaming_persistence_job()` - -**Read by**: Post-conversation transcription jobs - -**Retention**: Manual cleanup (no automatic deletion) - -### Redis Storage - -**Container**: `redis` - -**Volume**: `redis_data` (persistent) - -| Key Pattern | Type | Purpose | TTL | Created By | -|-------------|------|---------|-----|------------| -| `audio:stream:{client_id}` | Stream | Audio chunks for transcription | None (MAXLEN=25k) | AudioStreamProducer | -| `audio:session:{session_id}` | Hash | Session metadata | 1 hour | AudioStreamProducer | -| `transcription:results:{session_id}` | Stream | Transcription results | Manual delete | Transcription consumers | -| `transcription:interim:{session_id}` | Pub/Sub | Real-time interim results | N/A (ephemeral) | Streaming consumer | -| `conversation:current:{session_id}` | String | Current conversation ID | 24 hours | open_conversation_job | -| `audio:file:{conversation_id}` | String | Audio file path | 24 hours | audio_persistence_job | -| `session:conversation_count:{session_id}` | Counter | Conversation count | 1 hour | handle_end_of_conversation | -| `speech_detection_job:{client_id}` | String | Job ID for cleanup | 1 hour | speech_detection_job | -| `rq:job:{job_id}` | Hash | RQ job metadata | 24 hours (default) | RQ | - -### Vector Storage (Memory) - -#### Option A: Qdrant (Chronicle Native Provider) - -**Container**: `qdrant` - -**Volume**: `qdrant_data` (persistent) - -**Ports**: 6333 (HTTP), 6334 (gRPC) - -**Collections**: User-specific collections for semantic embeddings - -**Written by**: `memory_extraction_job()` - -**Read by**: Memory search API (`/api/memories/search`) - -#### Option B: OpenMemory MCP - -**Container**: `openmemory-mcp` (external service) - -**Port**: 8765 - -**Protocol**: MCP (Model Context Protocol) - -**Collections**: Cross-client memory storage - -**Written by**: `memory_extraction_job()` (via MCP provider) - -**Read by**: Memory search API (via MCP provider) - -## Complete End-to-End Flow - -### Step-by-Step Data Journey - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 1. AUDIO INPUT │ -└─────────────────────────────────────────────────────────────────┘ - WebSocket (/ws) or File Upload (/audio/upload) - ↓ - Container: chronicle-backend - ↓ - AudioStreamProducer.init_session() - - Creates: audio:session:{session_id} (Redis) - - Initializes: In-memory buffer (chronicle-backend container) - ↓ - AudioStreamProducer.add_audio_chunk() - - Buffers: In-memory (chronicle-backend) - - Chunks: Fixed 0.25s chunks (8,000 bytes) - - Publishes: audio:stream:{client_id} (Redis) - - Returns: Redis message IDs - -┌─────────────────────────────────────────────────────────────────┐ -│ 2. SESSION-LEVEL JOB (RQ) │ -└─────────────────────────────────────────────────────────────────┘ - stream_speech_detection_job - Container: rq-worker - ↓ - Polls: TranscriptionResultsAggregator.get_combined_results() - Reads: transcription:results:{session_id} (Redis) - ↓ - Analyzes: Word count, duration, confidence - ↓ - When speech detected: - - Creates: Conversation document (MongoDB) - - Enqueues: open_conversation_job (RQ) - - Exits (restarts when conversation ends) - -┌─────────────────────────────────────────────────────────────────┐ -│ 3a. TRANSCRIPTION CONSUMER (Background Task) │ -└─────────────────────────────────────────────────────────────────┘ - StreamingTranscriptionConsumer (or BaseAudioStreamConsumer) - Container: chronicle-backend (Background Task) - ↓ - Reads: audio:stream:{client_id} (Redis, via XREADGROUP) - Consumer Group: streaming-transcription (or batch provider) - ↓ - STREAMING PATH: - • Opens: WebSocket to Deepgram - • Sends: Chunks immediately (no buffering) - • Publishes Interim: transcription:interim:{session_id} (Redis Pub/Sub) - • Publishes Final: transcription:results:{session_id} (Redis Stream) - • Triggers: Plugins on final results - - BATCH PATH: - • Buffers: 30 chunks (~7.5s) in memory (chronicle-backend) - • Combines: All buffered chunks - • Transcribes: Via provider API (Deepgram/Parakeet) - • Adjusts: Timestamps relative to session start - • Publishes: transcription:results:{session_id} (Redis Stream) - -┌─────────────────────────────────────────────────────────────────┐ -│ 3b. AUDIO PERSISTENCE CONSUMER (RQ Job) │ -└─────────────────────────────────────────────────────────────────┘ - audio_streaming_persistence_job - Container: rq-worker - ↓ - Reads: audio:stream:{client_id} (Redis, via XREADGROUP) - Consumer Group: audio_persistence - ↓ - Monitors: conversation:current:{session_id} (Redis) - ↓ - For each conversation: - • Opens: New WAV file (data/chunks/, chronicle-backend volume) - • Writes: Chunks immediately (real-time) - • Stores: audio:file:{conversation_id} = path (Redis) - ↓ - On rotation signal: - • Closes: Current file - • Opens: New file for next conversation - ↓ - On END signal: - • Closes: File - • Returns: Statistics (chunks, bytes, duration) - -┌─────────────────────────────────────────────────────────────────┐ -│ 4. CONVERSATION-LEVEL JOB (RQ) │ -└─────────────────────────────────────────────────────────────────┘ - open_conversation_job - Container: rq-worker - ↓ - Creates: Conversation document (MongoDB conversations collection) - ↓ - Sets: conversation:current:{session_id} = conversation_id (Redis) - → Triggers audio persistence job to rotate WAV file - ↓ - Polls: TranscriptionResultsAggregator for updates (1s intervals) - Reads: transcription:results:{session_id} (Redis) - ↓ - Tracks: Speech activity (inactivity timeout = 60s) - ↓ - Detects End: - - Inactivity (60s) - - User manual stop - - WebSocket disconnect - ↓ - Waits: For audio file path from persistence job - Reads: audio:file:{conversation_id} (Redis) - ↓ - Saves: audio_path to conversation document (MongoDB) - ↓ - Enqueues: POST-CONVERSATION PIPELINE (RQ) - -┌─────────────────────────────────────────────────────────────────┐ -│ 5. POST-CONVERSATION PIPELINE (RQ - Parallel Jobs) │ -└─────────────────────────────────────────────────────────────────┘ - All jobs run in parallel - Container: rq-worker - ↓ - Reads: Audio file from disk (data/chunks/*.wav) - - ┌─ transcribe_full_audio_job - │ - Batch transcribes: Complete audio file - │ - Validates: Meaningful speech - │ - Marks deleted: If no speech - │ - Stores: MongoDB (transcript, segments, words) - │ - │ └─ recognize_speakers_job (if enabled) - │ - Sends: Audio + segments to speaker-recognition service - │ - Identifies: Speakers via voice embeddings - │ - Updates: MongoDB (segment speaker labels) - │ - │ └─ memory_extraction_job - │ - Uses: LLM (OpenAI/Ollama) to extract facts - │ - Stores: Qdrant (Chronicle) or OpenMemory MCP (vector DB) - │ - └─ generate_title_summary_job - - Uses: LLM (OpenAI/Ollama) - - Generates: Title, summary, detailed_summary - - Stores: MongoDB (conversation document) - - └─ dispatch_conversation_complete_event_job - - Triggers: conversation.complete plugins - - Only for: File uploads (not streaming) - - All results stored: MongoDB conversations collection - -┌─────────────────────────────────────────────────────────────────┐ -│ 6. SESSION RESTART │ -└─────────────────────────────────────────────────────────────────┘ - handle_end_of_conversation() - Container: chronicle-backend - ↓ - Deletes: transcription:results:{session_id} (Redis) - ↓ - Increments: session:conversation_count:{session_id} (Redis) - ↓ - Checks: Session still active? (WebSocket connected) - ↓ - If active: - - Re-enqueues: stream_speech_detection_job (RQ) - - Session remains: "active" for next conversation -``` - -### Data Locations Summary - -| Stage | Data Type | Location | Container | -|-------|-----------|----------|-----------| -| Input | Audio bytes | In-memory buffers | chronicle-backend | -| Producer | Fixed chunks | `audio:stream:{client_id}` | redis | -| Session metadata | Hash | `audio:session:{session_id}` | redis | -| Transcription consumer | Interim results | `transcription:interim:{session_id}` (Pub/Sub) | redis | -| Transcription consumer | Final results | `transcription:results:{session_id}` (Stream) | redis | -| Audio persistence | WAV files | `data/chunks/*.wav` (disk volume) | chronicle-backend (volume) | -| Audio persistence | File paths | `audio:file:{conversation_id}` | redis | -| Conversation job | Conversation doc | MongoDB `conversations` | mongo | -| Post-processing | Transcript | MongoDB `conversations` | mongo | -| Post-processing | Memories | Qdrant or OpenMemory MCP | qdrant / openmemory-mcp | -| Post-processing | Title/summary | MongoDB `conversations` | mongo | - -## Key Design Patterns - -### 1. Speech-Driven Architecture - -**Principle**: Conversations only created when speech is detected - -**Benefits**: -- Clean user experience (no noise-only sessions in UI) -- Reduced memory processing load -- Automatic quality filtering - -**Implementation**: -- `audio_chunks` collection: Always stores sessions -- `conversations` collection: Only created with speech -- Speech detection: Analyzes word count, duration, confidence - -### 2. Versioned Processing - -**Principle**: Store multiple versions of transcripts/memories - -**Benefits**: -- Reprocess without losing originals -- A/B testing different providers -- Rollback to previous versions - -**Implementation**: -- `transcript_versions` dict with version IDs (v1, v2, ...) -- `active_transcript_version` pointer -- `segments` field mirrors active version (quick access) - -### 3. Session-Level vs Conversation-Level - -**Session**: WebSocket connection lifetime (multiple conversations) -- Duration: Up to 24 hours -- Job: `stream_speech_detection_job` -- Purpose: Continuous monitoring for speech - -**Conversation**: Speech burst between silence periods -- Duration: Typically minutes -- Job: `open_conversation_job` -- Purpose: Process single meaningful exchange - -**Benefits**: -- Continuous recording without manual start/stop -- Automatic conversation segmentation -- Efficient resource usage (one session, many conversations) - -### 4. Job Metadata Cascading - -**Pattern**: Parent jobs link to child jobs - -**Example**: -``` -speech_detection_job - ↓ job_id stored in -audio:session:{session_id} - ↓ creates -open_conversation_job - ↓ job_id stored in -conversation document - ↓ creates -post-conversation jobs (parallel) -``` - -**Benefits**: -- Job grouping and cleanup -- Dependency tracking -- Debugging (trace job lineage) - -### 5. Real-Time + Batch Hybrid - -**Real-Time Path** (Streaming Consumer): -- Low latency (interim results in <1 second) -- WebSocket to Deepgram -- Publishes to Pub/Sub for live UI updates - -**Batch Path** (Batch Consumer): -- High accuracy (more context) -- Buffers 7.5 seconds -- API-based transcription - -**Both paths** write to same `transcription:results:{session_id}` stream - -**Benefits**: -- Live UI updates (interim results) -- Accurate final results (batch processing) -- Provider flexibility (switch between streaming/batch) - -### 6. Fan-Out via Redis Consumer Groups - -**Pattern**: Multiple consumer groups read same stream - -**Example**: `audio:stream:{client_id}` consumed by: -- Transcription consumer group -- Audio persistence consumer group - -**Benefits**: -- Parallel processing paths -- Horizontal scaling (multiple workers per group) -- No message duplication (each group processes independently) - -### 7. File Rotation via Redis Signals - -**Pattern**: Conversation job signals persistence job via Redis key - -**Implementation**: -```python -# Conversation job -redis.set(f"conversation:current:{session_id}", conversation_id) - -# Persistence job (monitors key) -current_conv = redis.get(f"conversation:current:{session_id}") -if current_conv != last_conv: - close_current_file() - open_new_file(current_conv) -``` - -**Benefits**: -- Decoupled jobs (no direct communication) -- Real-time file rotation -- Multiple files per session (one per conversation) - -## Failure Handling - -### Transcription Errors - -**Detection**: `stream_speech_detection_job` polls results - -**Action**: -- Sets `transcription_error` field in `audio:session:{session_id}` -- Logs error for debugging -- Session remains active (can recover) - -### No Meaningful Speech - -**Detection**: `transcribe_full_audio_job` validates transcript - -**Criteria**: -- Word count < 10 -- Duration < 5 seconds -- All words low confidence - -**Action**: -- Marks conversation `deleted=True` -- Sets `end_reason="no_meaningful_speech"` -- Conversation hidden from UI - -### Audio File Not Ready - -**Detection**: `open_conversation_job` waits for file path - -**Timeout**: 30 seconds (configurable) - -**Action**: -- Marks conversation `deleted=True` -- Sets `end_reason="audio_file_not_ready"` -- Logs error for debugging - -### Job Zombies (Stuck Jobs) - -**Detection**: `check_job_alive()` utility - -**Method**: Checks Redis for job existence - -**Action**: -- Returns `False` if job missing -- Caller can retry or fail gracefully - -### Dead Consumers - -**Detection**: Consumer group lag monitoring - -**Cleanup**: -- Removes idle consumers (>30 seconds) -- Claims pending messages from dead consumers -- Redistributes to active workers - -### Stream Trimming - -**Prevention**: Streams don't grow unbounded - -**Implementation**: -- `XTRIM MAXLEN 25000` on `audio:stream:{client_id}` -- Keeps last 25k messages (~104 minutes @ 0.25s chunks) -- Deletes `transcription:results:{session_id}` after conversation ends - -### Session Timeout - -**Max Duration**: 24 hours - -**Action**: -- Jobs exit gracefully -- Session marked `"complete"` -- Resources cleaned up (streams deleted, consumer groups removed) - ---- - -## Conclusion - -Chronicle's audio pipeline is designed for: -- **Real-time processing**: Low-latency transcription and live UI updates -- **Horizontal scalability**: Redis Consumer Groups enable multiple workers -- **Fault tolerance**: Decoupled components, job retries, graceful error handling -- **Resource efficiency**: Speech-driven architecture filters noise automatically -- **Flexibility**: Pluggable providers (Deepgram/Parakeet, OpenAI/Ollama, Qdrant/OpenMemory) - -All coordinated through **Redis Streams** for data flow and **RQ** for orchestration, with **MongoDB** for final storage and **disk** for audio archives. diff --git a/Docs/ssl-certificates.md b/Docs/ssl-certificates.md deleted file mode 100644 index 1980c833..00000000 --- a/Docs/ssl-certificates.md +++ /dev/null @@ -1,73 +0,0 @@ -# SSL Certificates & HTTPS - -Chronicle uses automatic HTTPS setup for secure microphone access and remote connections. - -## Why HTTPS is Needed - -Modern browsers require HTTPS for: -- **Microphone access** over network (not localhost) -- **Secure WebSocket connections** (WSS) -- **Remote access** via Tailscale/VPN -- **Production deployments** - -## SSL Implementation - -### Advanced Backend → Caddy - -The main backend uses **Caddy** for automatic HTTPS: - -**Configuration**: `backends/advanced/Caddyfile` -**Activation**: Caddy starts when using `--profile https` or when wizard enables HTTPS -**Certificate**: Self-signed for local/Tailscale IPs, automatic Let's Encrypt for domains - -**Ports**: -- `443` - HTTPS (main access) -- `80` - HTTP (redirects to HTTPS) - -**Access**: `https://localhost` or `https://your-tailscale-ip` - -### Speaker Recognition → nginx - -The speaker recognition service uses **nginx** for HTTPS: - -**Configuration**: `extras/speaker-recognition/nginx.conf` -**Certificate**: Self-signed via `ssl/generate-ssl.sh` - -**Ports**: -- `8444` - HTTPS -- `8081` - HTTP (redirects to HTTPS) - -**Access**: `https://localhost:8444` - -## Setup via Wizard - -When you run `./wizard.sh`, the setup wizard: -1. Asks if you want to enable HTTPS -2. Prompts for your Tailscale IP or domain -3. Generates SSL certificates automatically -4. Configures Caddy/nginx as needed -5. Updates CORS settings for HTTPS origins - -**No manual setup required** - the wizard handles everything. - -## Browser Certificate Warnings - -Since we use self-signed certificates for local/Tailscale IPs, browsers will show security warnings: - -1. Click "Advanced" -2. Click "Proceed to localhost (unsafe)" or similar -3. Microphone access will now work - -For production with real domains, Caddy automatically obtains valid Let's Encrypt certificates. - -## Troubleshooting - -**HTTPS not working**: -- Check Caddy/nginx containers are running: `docker compose ps` -- Verify certificates exist: `ls backends/advanced/ssl/` or `ls extras/speaker-recognition/ssl/` -- Check you're using `https://` not `http://` - -**Microphone not accessible**: -- Ensure you're accessing via HTTPS (not HTTP) -- Accept browser certificate warning -- Verify you're not using `localhost` from remote device (use Tailscale IP instead) diff --git a/LICENSE b/LICENSE index 6e9268e9..4130f88b 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/Makefile b/Makefile index d821819e..79617249 100644 --- a/Makefile +++ b/Makefile @@ -74,11 +74,11 @@ help: ## Show detailed help for all targets @echo "🏗️ KUBERNETES SETUP:" @echo " setup-k8s Complete initial Kubernetes setup" @echo " - Configures insecure registry access" - @echo " - Sets up infrastructure services (MongoDB, Qdrant)" + @echo " - Sets up infrastructure services (MongoDB, FalkorDB)" @echo " - Creates shared models PVC" @echo " - Sets up cross-namespace RBAC" @echo " - Generates and applies configuration" - @echo " setup-infrastructure Deploy infrastructure services (MongoDB, Qdrant)" + @echo " setup-infrastructure Deploy infrastructure services (MongoDB, FalkorDB)" @echo " setup-rbac Set up cross-namespace RBAC" @echo " setup-storage-pvc Create shared models PVC" @echo @@ -122,8 +122,9 @@ help: ## Show detailed help for all targets setup-dev: ## Setup development environment (git hooks, pre-commit) @echo "🛠️ Setting up development environment..." @echo "" + @bash scripts/check_uv.sh @echo "📦 Installing pre-commit..." - @pip install pre-commit 2>/dev/null || pip3 install pre-commit + @uv tool install pre-commit @echo "" @echo "🔧 Installing git hooks..." @pre-commit install --hook-type pre-push @@ -148,7 +149,7 @@ setup-k8s: ## Initial Kubernetes setup (registry + infrastructure) @echo @echo "📋 Setup includes:" @echo " • Insecure registry configuration" - @echo " • Infrastructure services (MongoDB, Qdrant)" + @echo " • Infrastructure services (MongoDB, FalkorDB)" @echo " • Shared models PVC for speaker recognition" @echo " • Cross-namespace RBAC" @echo " • Configuration generation and application" @@ -177,13 +178,12 @@ setup-k8s: ## Initial Kubernetes setup (registry + infrastructure) @echo " • Run 'make k8s-status' to check cluster status" @echo " • Run 'make help' for more options" -setup-infrastructure: ## Set up infrastructure services (MongoDB, Qdrant) +setup-infrastructure: ## Set up infrastructure services (MongoDB, FalkorDB) @echo "🏗️ Setting up infrastructure services..." - @echo "Deploying MongoDB and Qdrant to $(INFRASTRUCTURE_NAMESPACE) namespace..." + @echo "Deploying MongoDB and FalkorDB to $(INFRASTRUCTURE_NAMESPACE) namespace..." @set -a; source skaffold.env; set +a; skaffold run --profile=infrastructure --default-repo=$(CONTAINER_REGISTRY) @echo "⏳ Waiting for infrastructure services to be ready..." @kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=mongodb -n $(INFRASTRUCTURE_NAMESPACE) --timeout=300s || echo "⚠️ MongoDB not ready yet" - @kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=qdrant -n $(INFRASTRUCTURE_NAMESPACE) --timeout=300s || echo "⚠️ Qdrant not ready yet" @echo "✅ Infrastructure services deployed" setup-rbac: ## Set up cross-namespace RBAC diff --git a/README-K8S.md b/README-K8S.md index 8bbe22fa..5a243400 100644 --- a/README-K8S.md +++ b/README-K8S.md @@ -47,7 +47,7 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu 2. **Install Ubuntu Server** - Boot from USB/DVD - Choose "Install Ubuntu Server" - - Configure network with static IP (recommended: 192.168.1.42) + - Configure network with static IP (recommended: 192.168.1.42) - Set hostname (e.g., `k8s_control_plane`) - Create user account - Install OpenSSH server @@ -56,10 +56,10 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # Update system sudo apt update && sudo apt upgrade -y - + # Install essential packages sudo apt install -y curl wget git vim htop tree - + # Configure firewall sudo ufw allow ssh sudo ufw allow 6443 # Kubernetes API @@ -75,11 +75,11 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # Install MicroK8s sudo snap install microk8s --classic - + # Add user to microk8s group sudo usermod -a -G microk8s $USER sudo chown -f -R $USER ~/.kube - + # Log out and back in, or run: newgrp microk8s ``` @@ -88,10 +88,10 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # Start MicroK8s sudo microk8s start - + # Wait for all services to be ready sudo microk8s status --wait-ready - + # Generate join token for worker nodes sudo microk8s add-node # This will output a command like: @@ -103,7 +103,7 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # Start MicroK8s sudo microk8s start - + # Wait for all services to be ready sudo microk8s status --wait-ready ``` @@ -115,7 +115,7 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu sudo microk8s enable ingress sudo microk8s enable storage sudo microk8s enable metrics-server - + # Wait for add-ons to be ready sudo microk8s status --wait-ready ``` @@ -125,7 +125,7 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu # Create kubectl alias echo 'alias kubectl="microk8s kubectl"' >> ~/.bashrc source ~/.bashrc - + # Verify installation kubectl get nodes kubectl get pods -A @@ -147,11 +147,11 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # Install MicroK8s sudo snap install microk8s --classic - + # Add user to microk8s group sudo usermod -a -G microk8s $USER sudo chown -f -R $USER ~/.kube - + # Log out and back in, or run: newgrp microk8s ``` @@ -161,7 +161,7 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu # Use the join command from the control plane # Replace with your actual join token sudo microk8s join 192.168.1.42:25000/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx - + # Wait for node to join sudo microk8s status --wait-ready ``` @@ -170,7 +170,7 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # On the control plane, verify the worker node joined kubectl get nodes - + # The worker node should show as Ready # Example output: # NAME STATUS ROLES AGE VERSION @@ -182,7 +182,7 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # From your build machine, configure the worker node ./configure-insecure-registry-remote.sh 192.168.1.43 - + # Repeat for each worker node with their respective IPs ``` @@ -194,10 +194,10 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # Enable the built-in MicroK8s registry (not enabled by default) sudo microk8s enable registry - + # Wait for registry to be ready sudo microk8s status --wait-ready - + # Verify registry is running kubectl get pods -n container-registry ``` @@ -213,11 +213,11 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # From your build machine, configure MicroK8s to trust the insecure registry chmod +x scripts/configure-insecure-registry-remote.sh - + # Run the configuration script with your node IP address # Usage: ./scripts/configure-insecure-registry-remote.sh [ssh_user] ./scripts/configure-insecure-registry-remote.sh 192.168.1.42 - + # Or with custom SSH user: # ./scripts/configure-insecure-registry-remote.sh 192.168.1.42 myuser ``` @@ -234,7 +234,7 @@ This guide walks you through setting up Chronicle from scratch on a fresh Ubuntu ```bash # Apply the hostpath provisioner kubectl apply -f k8s-manifests/hostpath-provisioner-official.yaml - + # Verify storage class kubectl get storageclass ``` @@ -281,19 +281,19 @@ chronicle/ > **Note:** The `--recursive` flag downloads the optional Mycelia submodule (an alternative memory backend with timeline visualization). Most deployments use the default Chronicle memory system and don't need Mycelia. 2. **Install Required Tools** - + **kubectl** (required for Skaffold and Helm): - Visit: https://kubernetes.io/docs/tasks/tools/ - Follow the official installation guide for your platform - + **Skaffold**: - Visit: https://skaffold.dev/docs/install/ - - Follow the official installation guide - + - Follow the official installation guide + **Helm**: - Visit: https://helm.sh/docs/intro/install/ - - Follow the official installation guide - + - Follow the official installation guide + **Verify installations:** ```bash kubectl version --client @@ -311,53 +311,52 @@ chronicle/ ```bash # Copy template (if it exists) # cp backends/advanced/.env.template backends/advanced/.env - + # Note: Most environment variables are automatically set by Skaffold during deployment - # including MONGODB_URI, QDRANT_BASE_URL, and other Kubernetes-specific values + # including MONGODB_URI and other Kubernetes-specific values ``` 2. **Configure Skaffold Environment** ```bash # Copy the template file cp skaffold.env.template skaffold.env - + # Edit skaffold.env with your specific values vim skaffold.env - + # Essential variables to configure: REGISTRY=192.168.1.42:32000 # Use IP address for immediate access # Alternative: REGISTRY=k8s_control_plane:32000 (requires adding 'k8s_control_plane 192.168.1.42' to /etc/hosts) BACKEND_IP=192.168.1.42 BACKEND_NODEPORT=30270 WEBUI_NODEPORT=31011 - + # Optional: Configure speaker recognition service HF_TOKEN=hf_your_huggingface_token_here DEEPGRAM_API_KEY=your_deepgram_api_key_here - - # Note: MONGODB_URI and QDRANT_BASE_URL are automatically generated - # by Skaffold based on your infrastructure namespace and service names + + # Note: MONGODB_URI is automatically generated by Skaffold based on + # your infrastructure namespace and service names ``` 3. **Configuration Variables Reference** - + **Required Variables:** - `REGISTRY`: Docker registry for image storage - `BACKEND_IP`: IP address of your Kubernetes control plane - `BACKEND_NODEPORT`: Port for backend service (30000-32767) - `WEBUI_NODEPORT`: Port for WebUI service (30000-32767) - - `INFRASTRUCTURE_NAMESPACE`: Namespace for MongoDB and Qdrant + - `INFRASTRUCTURE_NAMESPACE`: Namespace for MongoDB and FalkorDB - `APPLICATION_NAMESPACE`: Namespace for your application - + **Optional Variables (for Speaker Recognition):** - `HF_TOKEN`: Hugging Face token for Pyannote models - `DEEPGRAM_API_KEY`: Deepgram API key for speech-to-text - `COMPUTE_MODE`: GPU or CPU mode for ML services - `SIMILARITY_THRESHOLD`: Speaker identification threshold - + **Automatically Generated:** - `MONGODB_URI`: Generated from infrastructure namespace - - `QDRANT_BASE_URL`: Generated from infrastructure namespace - `IMAGE_REPO_*`: Generated from Skaffold build process - `IMAGE_TAG_*`: Generated from Skaffold build process @@ -365,11 +364,11 @@ chronicle/ ```bash # Note: Most environment variables are handled by Skaffold automatically # If you need custom environment variables, you can: - + # Option 1: Use the script (if it exists) # chmod +x scripts/generate-helm-configmap.sh # ./scripts/generate-helm-configmap.sh - + # Option 2: Add them directly to the Helm chart values # Edit backends/charts/advanced-backend/values.yaml ``` @@ -460,9 +459,9 @@ This directory contains standalone Kubernetes manifests that are not managed by ```bash # Deploy everything in the correct order ./scripts/deploy-all-services.sh - + # This will automatically: - # - Deploy infrastructure (MongoDB, Qdrant) + # - Deploy infrastructure (MongoDB, FalkorDB) # - Deploy main application (Backend, WebUI) # - Deploy additional services (if configured) # - Wait for each service to be ready @@ -473,13 +472,13 @@ This directory contains standalone Kubernetes manifests that are not managed by ```bash # Deploy infrastructure first skaffold run --profile=infrastructure - + # Wait for infrastructure to be ready kubectl get pods -n root - + # Deploy main application skaffold run --profile=advanced-backend --default-repo=192.168.1.42:32000 - + # Monitor deployment skaffold run --profile=advanced-backend --default-repo=192.168.1.42:32000 --tail ``` @@ -489,10 +488,10 @@ This directory contains standalone Kubernetes manifests that are not managed by # Check all resources kubectl get all -n chronicle kubectl get all -n root - + # Check Ingress kubectl get ingress -n chronicle - + # Check services kubectl get svc -n chronicle ``` @@ -636,7 +635,7 @@ spec: ```bash # Check backend health curl -k https://chronicle.192-168-1-42.nip.io:32623/health - + # Check WebUI curl -k https://chronicle.192-168-1-42.nip.io:32623/ ``` @@ -660,7 +659,7 @@ spec: ```bash # Test registry connectivity (run on Kubernetes node) curl http://k8s_control_plane:32000/v2/ - + # Check MicroK8s containerd config (run on Kubernetes node) sudo cat /var/snap/microk8s/current/args/certs.d/k8s_control_plane:32000/hosts.toml ``` @@ -669,7 +668,7 @@ spec: ```bash # Check storage class (run on build machine) kubectl get storageclass - + # Check persistent volumes (run on build machine) kubectl get pv kubectl get pvc -A @@ -679,7 +678,7 @@ spec: ```bash # Check Ingress controller (run on build machine) kubectl get pods -n ingress-nginx - + # Check Ingress configuration (run on build machine) kubectl describe ingress -n chronicle ``` @@ -696,13 +695,13 @@ spec: # Check GPU operator status (run on build machine) kubectl get pods -n gpu-operator kubectl describe pod -n gpu-operator - + # Check GPU detection on nodes kubectl get nodes -o json | jq '.items[] | {name: .metadata.name, gpu: .status.allocatable."nvidia.com/gpu"}' - + # Check GPU operator logs kubectl logs -n gpu-operator deployment/gpu-operator - + # Verify NVIDIA drivers on host (run on Kubernetes node) nvidia-smi ``` @@ -712,18 +711,18 @@ spec: # Check node connectivity (run on build machine) kubectl get nodes kubectl describe node - + # Check node status and conditions kubectl get nodes -o json | jq '.items[] | {name: .metadata.name, status: .status.conditions[] | select(.type=="Ready") | .status, message: .message}' - + # Check if pods can be scheduled kubectl get pods -A -o wide kubectl describe pod -n - + # Check node resources and capacity kubectl top nodes kubectl describe node | grep -A 10 "Allocated resources" - + # Verify network connectivity between nodes # Run on each node: ping @@ -766,7 +765,7 @@ kubectl rollout restart deployment/webui -n chronicle ```bash # Update system packages sudo apt update && sudo apt upgrade -y - + # Update MicroK8s sudo snap refresh microk8s ``` @@ -776,7 +775,7 @@ kubectl rollout restart deployment/webui -n chronicle # Backup environment files (run on build machine) cp backends/advanced/.env backends/advanced/.env.backup cp skaffold.env skaffold.env.backup - + # Backup Kubernetes manifests (run on build machine) kubectl get all -n chronicle -o yaml > chronicle-backup.yaml kubectl get all -n root -o yaml > infrastructure-backup.yaml @@ -818,8 +817,7 @@ This script handles speaker recognition service deployment with proper environme For additional support: - Check the main [README.md](README.md) -- Review [CLAUDE.md](CLAUDE.md) for development notes -- Check [README-skaffold.md](README-skaffold.md) for Skaffold-specific information +- Review [AGENTS.md](AGENTS.md) for development notes --- diff --git a/README.md b/README.md index 7e342210..a775f83f 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,15 @@ Self-hostable AI system that captures audio/video data from OMI devices and other sources to generate memories, action items, and contextual insights about your conversations and daily interactions. -## Quick Start → [Get Started](quickstart.md) +## Quick Start -Run setup wizard, start services, access at http://localhost:5173 +```bash +curl -fsSL https://raw.githubusercontent.com/SimpleOpenSoftware/chronicle/main/install.sh | sh +``` + +This clones the latest release, installs dependencies, and launches the interactive setup wizard. + +For step-by-step instructions, see the [setup guide](quickstart.md). ## Screenshots @@ -16,9 +22,11 @@ Run setup wizard, start services, access at http://localhost:5173 ![Memory Search](.assets/memory-dashboard.png) -*[Mobile App - Screenshot coming soon]* +### Desktop Menu Bar Client + +![Menu Bar Client](.assets/menu-bar-client.png) -![Mobile App](screenshots/mobile-app.png) +*[Mobile App - Screenshot coming soon]* ## What's Included @@ -30,8 +38,8 @@ Run setup wizard, start services, access at http://localhost:5173 ## Links - **📚 [Setup Guide](quickstart.md)** - Start here -- **🔧 [Full Documentation](CLAUDE.md)** - Comprehensive reference -- **🏗️ [Project Overview](Docs/overview.md)** - Architecture and vision +- **🔧 [Full Documentation](AGENTS.md)** - Comprehensive reference +- **🏗️ [Project Overview](docs/overview.md)** - Architecture and vision - **🐳 [Docker/K8s](README-K8S.md)** - Container deployment ## Project Structure @@ -52,7 +60,7 @@ chronicle/ │ ├── speaker-recognition/ # Voice identification service │ ├── asr-services/ # Offline speech-to-text (Parakeet) │ └── openmemory-mcp/ # External memory server -├── Docs/ # Technical documentation +├── docs/ # Technical documentation ├── config/ # Central configuration files ├── tests/ # Integration & unit tests ├── wizard.py # Root setup orchestrator @@ -77,9 +85,9 @@ chronicle/ │ ┌────────────────────┴────────────────┐ │ │ │ │ │ │ ┌────▼─────┐ ┌───────────┐ ┌──────────▼──┐ │ -│ │ Deepgram │ │ OpenAI │ │ Qdrant │ │ -│ │ STT │ │ LLM │ │ (Vector │ │ -│ │ │ │ │ │ Store) │ │ +│ │ Deepgram │ │ OpenAI │ │ FalkorDB │ │ +│ │ STT │ │ LLM │ │ (Graph + │ │ +│ │ │ │ │ │ Vector) │ │ │ └──────────┘ └───────────┘ └─────────────┘ │ │ │ │ Optional Services: │ @@ -162,7 +170,7 @@ Usecases are numerous - OMI Mentor is one of them. Friend/Omi/pendants are a sma Regardless - this repo will try to do the minimal of this - multiple OMI-like audio devices feeding audio data - and from it: - Memories -- Action items +- Action items - Home automation ## Golden Goals (Not Yet Achieved) @@ -171,4 +179,3 @@ Regardless - this repo will try to do the minimal of this - multiple OMI-like au - **Home automation integration** (planned) - **Multi-device coordination** (planned) - **Visual context capture** (smart glasses integration planned) - diff --git a/app/.easignore b/app/.easignore new file mode 100644 index 00000000..54c84429 --- /dev/null +++ b/app/.easignore @@ -0,0 +1,46 @@ +# NOTE: .easignore fully replaces .gitignore for EAS uploads. +# Must exclude everything we don't want in the build tarball. + +# Dependencies — reinstalled on EAS build server +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native build outputs — regenerated on EAS build servers +android/.gradle/ +android/build/ +android/app/build/ +android/app/.cxx/ +ios/build/ +ios/Pods/ +ios/DerivedData/ +ios/*.xcworkspace/xcuserdata/ +ios/*.xcodeproj/xcuserdata/ +ios/*.xcodeproj/project.xcworkspace/xcuserdata/ + +# Local artifacts +*.ipa +*.apk +*.aab +build-*.ipa + +# Credentials (EAS uses server-side credentials) +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.pem + +# Metro / debug +.metro-health-check* +npm-debug.* +yarn-debug.* +yarn-error.* + +# OS / editor +.DS_Store +*.log diff --git a/app/.gitignore b/app/.gitignore index 6bf33056..f6e07bb9 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -34,4 +34,4 @@ yarn-error.* # typescript *.tsbuildinfo -android/* \ No newline at end of file +android/* diff --git a/app/App.tsx b/app/App.tsx index 60e44938..0397f908 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -1,3 +1,3 @@ // App.tsx import App from './app/index'; // your actual entry file -export default App; \ No newline at end of file +export default App; diff --git a/app/README.md b/app/README.md index e85e83e5..7041c19b 100644 --- a/app/README.md +++ b/app/README.md @@ -174,7 +174,7 @@ Stream audio directly from your phone's microphone to Chronicle backend, bypassi #### Requirements - **iOS**: iOS 13+ with microphone permissions -- **Android**: Android API 21+ with microphone permissions +- **Android**: Android API 21+ with microphone permissions - **Network**: Stable connection to Chronicle backend - **Backend**: Advanced backend running with `/ws?codec=pcm` endpoint @@ -191,7 +191,7 @@ Stream audio directly from your phone's microphone to Chronicle backend, bypassi - **Network Connection**: Test backend connectivity - **Authentication**: Verify JWT token is valid -#### Poor Audio Quality +#### Poor Audio Quality - **Check Signal Strength**: Ensure stable network connection - **Reduce Background Noise**: Use in quiet environment - **Restart Recording**: Stop and restart phone audio streaming @@ -365,4 +365,4 @@ BluetoothService.onAudioData = (audioBuffer) => { - **[Backend Setup](../backends/)**: Choose and configure backend services - **[Quick Start Guide](../quickstart.md)**: Complete system setup - **[Advanced Backend](../backends/advanced/)**: Full-featured backend option -- **[Simple Backend](../backends/simple/)**: Basic backend for testing \ No newline at end of file +- **[Simple Backend](../backends/simple/)**: Basic backend for testing diff --git a/app/app.json b/app/app.json index 66fbb8c2..3772aa2f 100644 --- a/app/app.json +++ b/app/app.json @@ -1,12 +1,12 @@ { "expo": { - "name": "friend-lite-app", + "name": "chronicle", "slug": "friend-lite-app", - "version": "1.0.0", + "version": "1.0.8", + "scheme": "chronicle", "orientation": "portrait", "icon": "./assets/icon.png", - "entryPoint": "./app/index.tsx", - "userInterfaceStyle": "light", + "userInterfaceStyle": "automatic", "splash": { "image": "./assets/splash.png", "resizeMode": "contain", @@ -17,9 +17,15 @@ ], "ios": { "supportsTablet": true, - "bundleIdentifier": "com.cupbearer5517.friendlite", + "bundleIdentifier": "com.cupbearer5517.chronicle", "infoPlist": { - "NSMicrophoneUsageDescription": "Friend Lite needs access to your microphone to stream audio to the backend for processing." + "NSCameraUsageDescription": "Chronicle uses the camera to scan QR codes for backend connection setup.", + "NSMicrophoneUsageDescription": "Chronicle needs access to your microphone to stream audio to the backend for processing.", + "NSAppTransportSecurity": { + "NSAllowsArbitraryLoads": true, + "NSAllowsLocalNetworking": true + }, + "ITSAppUsesNonExemptEncryption": false } }, "android": { @@ -27,7 +33,7 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "package": "com.cupbearer5517.friendlite", + "package": "com.cupbearer5517.chronicle", "permissions": [ "android.permission.BLUETOOTH", "android.permission.BLUETOOTH_ADMIN", @@ -36,11 +42,19 @@ "android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE_DATA_SYNC", "android.permission.POST_NOTIFICATIONS", - "android.permission.RECORD_AUDIO" - ], - "usesCleartextTraffic": true + "android.permission.RECORD_AUDIO", + "android.permission.CAMERA", + "android.permission.BLUETOOTH", + "android.permission.BLUETOOTH_ADMIN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_DATA_SYNC", + "android.permission.POST_NOTIFICATIONS", + "android.permission.RECORD_AUDIO", + "android.permission.CAMERA" + ] }, - "newArchEnabled": true, "plugins": [ [ "@siteed/expo-audio-studio", @@ -49,7 +63,10 @@ "enableNotifications": true, "enableBackgroundAudio": true, "enableDeviceDetection": true, - "iosBackgroundModes": { "useProcessing": true }, + "iosBackgroundModes": { + "useAudio": true, + "useProcessing": true + }, "iosConfig": { "microphoneUsageDescription": "We use the mic for live audio streaming" } @@ -79,6 +96,7 @@ } } ], + "expo-audio", [ "expo-build-properties", { @@ -91,12 +109,27 @@ ] } } - ] + ], + [ + "expo-camera", + { + "cameraPermission": "Chronicle uses the camera to scan QR codes for backend connection setup." + } + ], + "expo-image-picker", + "./plugins/with-ats", + "expo-asset", + "expo-secure-store" ], + "owner": "cupbearer5517", "extra": { "eas": { "projectId": "05d8598e-6fe7-4373-81e4-1654f3d8e181" } + }, + "runtimeVersion": "1.0.0", + "updates": { + "enabled": false } } -} \ No newline at end of file +} diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index d2a8b0bc..49bc72d5 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -1,5 +1,36 @@ +import { useEffect } from "react"; import { Stack } from "expo-router"; +import { useTheme } from "@/theme"; +import { ConnectionLogProvider } from "@/contexts/ConnectionLogContext"; +import { AppSettingsProvider } from "@/contexts/AppSettingsContext"; +import ErrorBoundary from "@/components/ErrorBoundary"; +import { initLogger, logInfo } from "@/utils/logger"; export default function RootLayout() { - return ; + const { colors, isDark } = useTheme(); + + useEffect(() => { + initLogger().then(() => logInfo('RootLayout', 'app mounted')); + }, []); + + return ( + + + + + + + + + + + + ); } diff --git a/app/app/components/BackendStatus.tsx b/app/app/components/BackendStatus.tsx deleted file mode 100644 index 4f55d37f..00000000 --- a/app/app/components/BackendStatus.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'; - -interface BackendStatusProps { - backendUrl: string; - onBackendUrlChange: (url: string) => void; - jwtToken: string | null; -} - -interface HealthStatus { - status: 'unknown' | 'checking' | 'healthy' | 'unhealthy' | 'auth_required'; - message: string; - lastChecked?: Date; -} - -export const BackendStatus: React.FC = ({ - backendUrl, - onBackendUrlChange, - jwtToken, -}) => { - const [healthStatus, setHealthStatus] = useState({ - status: 'unknown', - message: 'Not checked', - }); - - const checkBackendHealth = async (showAlert: boolean = false) => { - if (!backendUrl.trim()) { - setHealthStatus({ - status: 'unhealthy', - message: 'Backend URL not set', - }); - return; - } - - setHealthStatus({ - status: 'checking', - message: 'Checking connection...', - }); - - try { - // Convert WebSocket URL to HTTP URL for health check - let baseUrl = backendUrl.trim(); - - // Handle different URL formats - if (baseUrl.startsWith('ws://')) { - baseUrl = baseUrl.replace('ws://', 'http://'); - } else if (baseUrl.startsWith('wss://')) { - baseUrl = baseUrl.replace('wss://', 'https://'); - } - - // Remove any WebSocket path if present - baseUrl = baseUrl.split('/ws')[0]; - - // Try health endpoint first - const healthUrl = `${baseUrl}/health`; - console.log('[BackendStatus] Checking health at:', healthUrl); - - const response = await fetch(healthUrl, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - ...(jwtToken ? { 'Authorization': `Bearer ${jwtToken}` } : {}), - }, - }); - - console.log('[BackendStatus] Health check response status:', response.status); - - if (response.ok) { - const healthData = await response.json(); - setHealthStatus({ - status: 'healthy', - message: `Connected (${healthData.status || 'OK'})`, - lastChecked: new Date(), - }); - - if (showAlert) { - Alert.alert('Connection Success', 'Successfully connected to backend!'); - } - } else if (response.status === 401 || response.status === 403) { - setHealthStatus({ - status: 'auth_required', - message: 'Authentication required', - lastChecked: new Date(), - }); - - if (showAlert) { - Alert.alert('Authentication Required', 'Please login to access the backend.'); - } - } else { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - } catch (error) { - console.error('[BackendStatus] Health check error:', error); - - let errorMessage = 'Connection failed'; - if (error instanceof Error) { - if (error.message.includes('Network request failed')) { - errorMessage = 'Network request failed - check URL and network connection'; - } else if (error.name === 'AbortError') { - errorMessage = 'Request timeout'; - } else { - errorMessage = error.message; - } - } - - setHealthStatus({ - status: 'unhealthy', - message: errorMessage, - lastChecked: new Date(), - }); - - if (showAlert) { - Alert.alert( - 'Connection Failed', - `Could not connect to backend: ${errorMessage}\n\nMake sure the backend is running and accessible.` - ); - } - } - }; - - // Auto-check health when backend URL or JWT token changes - useEffect(() => { - if (backendUrl.trim()) { - const timer = setTimeout(() => { - checkBackendHealth(false); - }, 500); // Debounce - - return () => clearTimeout(timer); - } - }, [backendUrl, jwtToken]); - - const getStatusColor = (status: HealthStatus['status']): string => { - switch (status) { - case 'healthy': - return '#4CD964'; - case 'checking': - return '#FF9500'; - case 'unhealthy': - return '#FF3B30'; - case 'auth_required': - return '#FF9500'; - default: - return '#8E8E93'; - } - }; - - const getStatusIcon = (status: HealthStatus['status']): string => { - switch (status) { - case 'healthy': - return '✅'; - case 'checking': - return '🔄'; - case 'unhealthy': - return '❌'; - case 'auth_required': - return '🔐'; - default: - return '❓'; - } - }; - - return ( - - Backend Connection - - Backend URL: - - - - - Status: - - {getStatusIcon(healthStatus.status)} - - {healthStatus.message} - - {healthStatus.status === 'checking' && ( - - )} - - - - {healthStatus.lastChecked && ( - - Last checked: {healthStatus.lastChecked.toLocaleTimeString()} - - )} - - - checkBackendHealth(true)} - disabled={healthStatus.status === 'checking'} - > - - {healthStatus.status === 'checking' ? 'Checking...' : 'Test Connection'} - - - - - Enter the WebSocket URL of your backend server. Simple backend: http://localhost:8000/ (no auth). - Advanced backend: http://localhost:8080/ (requires login). Status is automatically checked. - The websocket URL can be different or the same as the HTTP URL, with /ws endpoint and codec parameter (e.g., /ws?codec=pcm) - - - ); -}; - -const styles = StyleSheet.create({ - section: { - marginBottom: 25, - padding: 15, - backgroundColor: 'white', - borderRadius: 10, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 2, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 15, - color: '#333', - }, - inputLabel: { - fontSize: 14, - color: '#333', - marginBottom: 5, - fontWeight: '500', - }, - textInput: { - backgroundColor: '#f0f0f0', - borderWidth: 1, - borderColor: '#ddd', - borderRadius: 6, - padding: 10, - fontSize: 14, - width: '100%', - marginBottom: 15, - color: '#333', - }, - statusContainer: { - marginBottom: 15, - padding: 10, - backgroundColor: '#f8f9fa', - borderRadius: 6, - borderWidth: 1, - borderColor: '#e9ecef', - }, - statusRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - statusLabel: { - fontSize: 14, - fontWeight: '500', - color: '#333', - }, - statusValue: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - justifyContent: 'flex-end', - }, - statusIcon: { - fontSize: 16, - marginRight: 6, - }, - statusText: { - fontSize: 14, - fontWeight: '500', - }, - lastCheckedText: { - fontSize: 12, - color: '#666', - marginTop: 5, - textAlign: 'center', - fontStyle: 'italic', - }, - button: { - backgroundColor: '#007AFF', - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 8, - alignItems: 'center', - marginBottom: 10, - elevation: 2, - }, - buttonDisabled: { - backgroundColor: '#A0A0A0', - opacity: 0.7, - }, - buttonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, - helpText: { - fontSize: 12, - color: '#666', - textAlign: 'center', - fontStyle: 'italic', - }, -}); - -export default BackendStatus; \ No newline at end of file diff --git a/app/app/components/DeviceDetails.tsx b/app/app/components/DeviceDetails.tsx deleted file mode 100644 index ebf204c3..00000000 --- a/app/app/components/DeviceDetails.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, TextInput } from 'react-native'; -import { BleAudioCodec } from 'friend-lite-react-native'; - -interface DeviceDetailsProps { - // Device Info - connectedDeviceId: string | null; - onGetAudioCodec: () => void; - currentCodec: BleAudioCodec | null; - onGetBatteryLevel: () => void; - batteryLevel: number; - - // Audio Listener - isListeningAudio: boolean; - onStartAudioListener: () => void; - onStopAudioListener: () => void; - audioPacketsReceived: number; - - // WebSocket URL for custom backend - webSocketUrl: string; - onSetWebSocketUrl: (url: string) => void; - - // Custom Audio Streamer Status - isAudioStreaming: boolean; - isConnectingAudioStreamer: boolean; - audioStreamerError: string | null; - - // User ID Management - userId: string; - onSetUserId: (userId: string) => void; - - // Audio Listener Retry State - isAudioListenerRetrying?: boolean; - audioListenerRetryAttempts?: number; -} - -export const DeviceDetails: React.FC = ({ - connectedDeviceId, - onGetAudioCodec, - currentCodec, - onGetBatteryLevel, - batteryLevel, - isListeningAudio, - onStartAudioListener, - onStopAudioListener, - audioPacketsReceived, - webSocketUrl, - onSetWebSocketUrl, - isAudioStreaming, - isConnectingAudioStreamer, - audioStreamerError, - userId, - onSetUserId, - isAudioListenerRetrying, - audioListenerRetryAttempts -}) => { - if (!connectedDeviceId) return null; - - - return ( - - Device Functions - - {/* Audio Codec */} - - Get Audio Codec - - {currentCodec && ( - - Current Audio Codec: - {currentCodec} - - )} - - {/* Battery Level */} - - Get Battery Level - - {batteryLevel >= 0 && ( - - Battery Level: - - - {batteryLevel}% - - - )} - - {/* User ID Management */} - - User ID (optional) - Enter User ID (for device identification): - - - - {userId && ( - - Current User ID: - {userId} - - )} - - - {/* Audio Controls */} - - - - {isListeningAudio ? "Stop Audio Listener" : - isAudioListenerRetrying ? "Stop Retry" : "Start Audio Listener"} - - - - {isAudioListenerRetrying && ( - - - 🔄 Retrying audio listener... (Attempt {audioListenerRetryAttempts || 0}/10) - - - )} - - {isListeningAudio && ( - - Audio Packets Received: - {audioPacketsReceived} - - )} - - - {/* Transcription Controls - Entire section REMOVED and replaced by WebSocket URL input */} - - Custom Audio Streaming - Backend WebSocket URL: - - - {/* Display Streamer Status */} - {isConnectingAudioStreamer && ( - Connecting to WebSocket... - )} - {isAudioStreaming && ( - Streaming audio to WebSocket... - )} - {audioStreamerError && ( - Error: {audioStreamerError} - )} - - - - ); -}; - -const styles = StyleSheet.create({ - section: { - marginBottom: 25, - padding: 15, - backgroundColor: 'white', - borderRadius: 10, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 2, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 15, - color: '#333', - }, - subSection: { - marginTop: 20, - }, - subSectionTitle: { - fontSize: 16, - fontWeight: '600', - marginBottom: 12, - color: '#444', - }, - button: { - backgroundColor: '#007AFF', - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 8, - alignItems: 'center', - elevation: 2, - }, - buttonWarning: { - backgroundColor: '#FF9500', - }, - buttonDisabled: { - backgroundColor: '#A0A0A0', - opacity: 0.7, - }, - buttonSecondary: { - backgroundColor: '#8E8E93', - }, - buttonSecondaryText: { - color: 'white', - }, - buttonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, - infoContainerSM: { - marginTop: 10, - padding: 10, - backgroundColor: '#f0f0f0', - borderRadius: 8, - alignItems: 'center', - }, - infoTitle: { - fontSize: 14, - fontWeight: '500', - color: '#555', - }, - infoValue: { - fontSize: 16, - fontWeight: 'bold', - color: '#007AFF', - marginTop: 5, - }, - infoValueLg: { - fontSize: 18, - fontWeight: 'bold', - color: '#FF9500', - marginTop: 5, - }, - batteryContainer: { - marginTop: 10, - padding: 12, - backgroundColor: '#f0f0f0', - borderRadius: 8, - alignItems: 'center', - borderLeftWidth: 4, - borderLeftColor: '#4CD964', - }, - batteryLevelDisplayContainer: { - width: '100%', - height: 24, - backgroundColor: '#e0e0e0', - borderRadius: 12, - marginTop: 8, - overflow: 'hidden', - position: 'relative', - }, - batteryLevelBar: { - height: '100%', - backgroundColor: '#4CD964', - borderRadius: 12, - position: 'absolute', - left: 0, - top: 0, - }, - batteryLevelText: { - position: 'absolute', - width: '100%', - textAlign: 'center', - lineHeight: 24, - fontSize: 12, - fontWeight: 'bold', - color: '#333', - }, - // Transcription Specific Styles - Some can be repurposed or removed - customStreamerSection: { - marginTop: 20, - paddingTop: 15, - borderTopWidth: 1, - borderTopColor: '#e0e0e0', - // alignItems: 'center', // No longer centering checkbox etc. - }, - inputLabel: { - fontSize: 14, - color: '#333', - marginBottom: 5, - fontWeight: '500', - }, - textInput: { - backgroundColor: '#f0f0f0', - borderWidth: 1, - borderColor: '#ddd', - borderRadius: 6, - padding: 10, - fontSize: 14, - width: '100%', // Ensure input takes full width of its container - marginBottom: 10, - color: '#333', - }, - statusText: { // New style for status messages - marginTop: 8, - fontSize: 13, - color: '#555', - textAlign: 'left', - }, - statusStreaming: { - color: 'green', - }, - statusError: { - color: 'red', - fontWeight: 'bold', - }, - retryContainer: { - marginTop: 10, - padding: 12, - backgroundColor: '#FFF3CD', - borderRadius: 8, - borderLeftWidth: 4, - borderLeftColor: '#FF9500', - }, - retryText: { - fontSize: 14, - color: '#856404', - fontWeight: '500', - textAlign: 'center', - }, -}); - -export default DeviceDetails; \ No newline at end of file diff --git a/app/app/components/DeviceListItem.tsx b/app/app/components/DeviceListItem.tsx deleted file mode 100644 index a8083035..00000000 --- a/app/app/components/DeviceListItem.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; -import { OmiDevice } from 'friend-lite-react-native'; - -interface DeviceListItemProps { - device: OmiDevice; - onConnect: (deviceId: string) => void; - onDisconnect: () => void; - isConnecting: boolean; - connectedDeviceId: string | null; -} - -export const DeviceListItem: React.FC = ({ - device, - onConnect, - onDisconnect, - isConnecting, - connectedDeviceId -}) => { - const isThisDeviceConnected = connectedDeviceId === device.id; - const isAnotherDeviceConnected = connectedDeviceId !== null && connectedDeviceId !== device.id; - - return ( - - - {device.name || 'Unknown Device'} - ID: {device.id} - {device.rssi != null && RSSI: {device.rssi} dBm} - - { - isThisDeviceConnected ? ( - - {isConnecting ? 'Disconnecting...' : 'Disconnect'} - - ) : ( - onConnect(device.id)} - disabled={isConnecting || isAnotherDeviceConnected} // Disable if connecting to this/another device or another device is connected - > - {isConnecting && connectedDeviceId === device.id ? 'Connecting...' : 'Connect'} - - ) - } - - ); -}; - -const styles = StyleSheet.create({ - deviceItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 5, // Added some horizontal padding - borderBottomWidth: 1, - borderBottomColor: '#eee', - }, - deviceInfoContainer: { - flex: 1, // Allow text to take available space and wrap if needed - marginRight: 10, // Space between text and button - }, - deviceName: { - fontSize: 16, - fontWeight: '500', - color: '#333', - }, - deviceInfo: { - fontSize: 12, - color: '#666', - marginTop: 2, - }, - button: { - backgroundColor: '#007AFF', - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 8, - alignItems: 'center', - elevation: 1, - }, - smallButton: { - paddingVertical: 8, - paddingHorizontal: 12, - }, - buttonDanger: { - backgroundColor: '#FF3B30', - }, - buttonDisabled: { - backgroundColor: '#A0A0A0', - opacity: 0.7, - }, - buttonText: { - color: 'white', - fontSize: 14, // Slightly smaller for small buttons - fontWeight: '600', - }, -}); - -export default DeviceListItem; \ No newline at end of file diff --git a/app/app/components/ObsidianIngest.tsx b/app/app/components/ObsidianIngest.tsx deleted file mode 100644 index d14ca367..00000000 --- a/app/app/components/ObsidianIngest.tsx +++ /dev/null @@ -1,154 +0,0 @@ - -import React, { useState } from 'react'; -import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'; - -interface ObsidianIngestProps { - backendUrl: string; - jwtToken: string | null; -} - -export const ObsidianIngest: React.FC = ({ - backendUrl, - jwtToken, -}) => { - const [vaultPath, setVaultPath] = useState('/app/data/obsidian_vault'); - const [loading, setLoading] = useState(false); - - const handleIngest = async () => { - if (!backendUrl) { - Alert.alert("Error", "Backend URL not set"); - return; - } - - if (!jwtToken) { - Alert.alert("Authentication Required", "Please login to ingest Obsidian vault."); - return; - } - - setLoading(true); - try { - let baseUrl = backendUrl.trim(); - // Handle different URL formats - if (baseUrl.startsWith('ws://')) { - baseUrl = baseUrl.replace('ws://', 'http://'); - } else if (baseUrl.startsWith('wss://')) { - baseUrl = baseUrl.replace('wss://', 'https://'); - } - baseUrl = baseUrl.split('/ws')[0]; - - const response = await fetch(`${baseUrl}/api/obsidian/ingest`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${jwtToken}` - }, - body: JSON.stringify({ vault_path: vaultPath }) - }); - - if (response.ok) { - Alert.alert("Success", "Ingestion started in background."); - } else { - const errorText = await response.text(); - Alert.alert("Error", `Ingestion failed: ${response.status} - ${errorText}`); - } - } catch (e) { - Alert.alert("Error", `Network request failed: ${e}`); - } finally { - setLoading(false); - } - }; - - return ( - - Obsidian Ingestion - - Vault Path (Backend Container): - - - - - {loading ? 'Starting Ingestion...' : 'Ingest to Neo4j'} - - - - - Enter the absolute path to the Obsidian vault INSIDE the backend container. - Ensure the folder is mounted to the container. - - - ); -}; - -const styles = StyleSheet.create({ - section: { - marginBottom: 25, - padding: 15, - backgroundColor: 'white', - borderRadius: 10, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 2, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 15, - color: '#333', - }, - inputLabel: { - fontSize: 14, - color: '#333', - marginBottom: 5, - fontWeight: '500', - }, - textInput: { - backgroundColor: '#f0f0f0', - borderWidth: 1, - borderColor: '#ddd', - borderRadius: 6, - padding: 10, - fontSize: 14, - width: '100%', - marginBottom: 15, - color: '#333', - }, - button: { - backgroundColor: '#9b59b6', // Purple for Obsidian - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 8, - alignItems: 'center', - marginBottom: 10, - elevation: 2, - }, - buttonDisabled: { - backgroundColor: '#A0A0A0', - opacity: 0.7, - }, - buttonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, - helpText: { - fontSize: 12, - color: '#666', - textAlign: 'center', - fontStyle: 'italic', - }, -}); - -export default ObsidianIngest; diff --git a/app/app/components/PhoneAudioButton.tsx b/app/app/components/PhoneAudioButton.tsx deleted file mode 100644 index 1f486e55..00000000 --- a/app/app/components/PhoneAudioButton.tsx +++ /dev/null @@ -1,201 +0,0 @@ -// PhoneAudioButton.tsx -import React from 'react'; -import { - TouchableOpacity, - Text, - View, - StyleSheet, - ActivityIndicator, -} from 'react-native'; - -interface PhoneAudioButtonProps { - isRecording: boolean; - isInitializing: boolean; - isDisabled: boolean; - audioLevel: number; - error: string | null; - onPress: () => void; -} - -const PhoneAudioButton: React.FC = ({ - isRecording, - isInitializing, - isDisabled, - audioLevel, - error, - onPress, -}) => { - - const getButtonStyle = () => { - if (isDisabled && !isRecording) { - return [styles.button, styles.buttonDisabled]; - } - if (isRecording) { - return [styles.button, styles.buttonRecording]; - } - if (error) { - return [styles.button, styles.buttonError]; - } - return [styles.button, styles.buttonIdle]; - }; - - const getButtonText = () => { - if (isInitializing) { - return 'Initializing...'; - } - if (isRecording) { - return 'Stop Phone Audio'; - } - return 'Stream Phone Audio'; - }; - - const getMicrophoneIcon = () => { - if (isRecording) { - return '🎤'; // Recording microphone - } - return '🎙️'; // Idle microphone - }; - - return ( - - - - {isInitializing ? ( - - ) : ( - - {getMicrophoneIcon()} - {getButtonText()} - - )} - - - - {/* Audio Level Indicator */} - {isRecording && ( - - - - - Audio Level - - )} - - {/* Status Message */} - {isRecording && ( - - Streaming audio to backend... - - )} - - {/* Error Message */} - {error && !isRecording && ( - {error} - )} - - {/* Disabled Message */} - {isDisabled && !isRecording && ( - - Disconnect Bluetooth device to use phone audio - - )} - - ); -}; - -const styles = StyleSheet.create({ - container: { - marginVertical: 10, - paddingHorizontal: 20, - }, - buttonWrapper: { - alignSelf: 'stretch', - }, - button: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 8, - minHeight: 48, - }, - buttonContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - buttonIdle: { - backgroundColor: '#007AFF', - }, - buttonRecording: { - backgroundColor: '#FF3B30', - }, - buttonDisabled: { - backgroundColor: '#C7C7CC', - }, - buttonError: { - backgroundColor: '#FF9500', - }, - buttonText: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: '600', - marginLeft: 8, - }, - icon: { - fontSize: 20, - }, - statusText: { - textAlign: 'center', - marginTop: 8, - fontSize: 12, - color: '#8E8E93', - }, - errorText: { - textAlign: 'center', - marginTop: 8, - fontSize: 12, - color: '#FF3B30', - }, - disabledText: { - textAlign: 'center', - marginTop: 8, - fontSize: 12, - color: '#8E8E93', - fontStyle: 'italic', - }, - audioLevelContainer: { - marginTop: 12, - alignItems: 'center', - }, - audioLevelBackground: { - width: '100%', - height: 4, - backgroundColor: '#E5E5EA', - borderRadius: 2, - overflow: 'hidden', - }, - audioLevelBar: { - height: '100%', - backgroundColor: '#34C759', - borderRadius: 2, - }, - audioLevelText: { - marginTop: 4, - fontSize: 10, - color: '#8E8E93', - }, -}); - -export default PhoneAudioButton; \ No newline at end of file diff --git a/app/app/diagnostics.tsx b/app/app/diagnostics.tsx new file mode 100644 index 00000000..d780a528 --- /dev/null +++ b/app/app/diagnostics.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { View, Text, FlatList, TouchableOpacity, StyleSheet, SafeAreaView, Share, Platform, Alert } from 'react-native'; +import { useTheme, ThemeColors } from '@/theme'; +import { useConnectionLog, ConnectionEvent, ConnectionEventType } from '@/contexts/ConnectionLogContext'; +import { getLogPath, readLog, clearLog } from '@/utils/logger'; + +const EVENT_BADGE_COLORS: Record = { + scan_start: '#007AFF', + scan_stop: '#8E8E93', + scan_result: '#5856D6', + connect_start: '#FF9500', + connect_success: '#34C759', + connect_fail: '#FF3B30', + disconnect: '#FF3B30', + battery_read: '#34C759', + audio_start: '#007AFF', + audio_stop: '#8E8E93', + error: '#FF3B30', + health_ping: '#34C759', + reconnect_attempt: '#FF9500', + reconnect_backoff: '#FF9500', + bt_state_change: '#5856D6', + ws_connecting: '#FF9500', + ws_open: '#34C759', + ws_close: '#FF3B30', + ws_error: '#FF3B30', + ws_reconnect: '#FF9500', + ws_reauth: '#AF52DE', + net_change: '#5856D6', +}; + +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function EventItem({ event, colors }: { event: ConnectionEvent; colors: ThemeColors }) { + const badgeColor = EVENT_BADGE_COLORS[event.type] || colors.textTertiary; + + return ( + + {formatTime(event.timestamp)} + + {event.type.replace(/_/g, ' ')} + + + {event.deviceName && {event.deviceName}} + {event.details && {event.details}} + {event.rssi != null && RSSI: {event.rssi} dBm} + + + ); +} + +const itemStyles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: 8, + paddingHorizontal: 12, + borderBottomWidth: 1, + }, + time: { + fontSize: 11, + fontFamily: 'monospace', + width: 65, + marginTop: 3, + }, + badge: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + marginRight: 8, + marginTop: 2, + }, + badgeText: { + color: 'white', + fontSize: 10, + fontWeight: '600', + textTransform: 'uppercase', + }, + details: { + flex: 1, + }, + device: { + fontSize: 13, + fontWeight: '500', + }, + detail: { + fontSize: 12, + marginTop: 1, + }, +}); + +export default function DiagnosticsScreen() { + const { colors } = useTheme(); + const { events, clearEvents } = useConnectionLog(); + + const shareLogFile = async () => { + try { + const contents = await readLog(); + if (!contents) { + Alert.alert('No log yet', 'The crash log file is empty.'); + return; + } + if (Platform.OS === 'ios') { + await Share.share({ url: `file://${getLogPath()}`, message: contents.slice(-4000) }); + } else { + await Share.share({ message: contents.slice(-4000) }); + } + } catch (err) { + Alert.alert('Share failed', String(err)); + } + }; + + const wipeLogFile = async () => { + Alert.alert('Clear crash log?', 'Removes the on-device crash log file.', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Clear', style: 'destructive', onPress: async () => { await clearLog(); } }, + ]); + }; + + return ( + + + Crash Log + {getLogPath()} + + + Share Log File + + + Clear File + + + + + Connection Log ({events.length}) + + Clear + + + + {events.length === 0 ? ( + + No events recorded yet. Scan or connect a device to see events here. + + ) : ( + } + keyExtractor={(item) => item.id} + style={{ backgroundColor: colors.card }} + /> + )} + + ); +} + +const screenStyles = StyleSheet.create({ + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + }, + title: { + fontSize: 17, + fontWeight: '600', + }, + clearButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + }, + clearText: { + fontSize: 14, + fontWeight: '500', + }, + empty: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 40, + }, + emptyText: { + fontSize: 15, + textAlign: 'center', + }, + logBar: { + paddingHorizontal: 16, + paddingVertical: 10, + borderBottomWidth: 1, + }, + logBarTitle: { + fontSize: 15, + fontWeight: '600', + }, + logBarPath: { + fontSize: 10, + fontFamily: 'monospace', + marginTop: 2, + marginBottom: 8, + }, + logBarRow: { + flexDirection: 'row', + gap: 8, + }, + logBtn: { + flex: 1, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 6, + alignItems: 'center', + }, + logBtnText: { + fontSize: 13, + fontWeight: '500', + }, +}); diff --git a/app/app/hooks/.gitkeep b/app/app/hooks/.gitkeep deleted file mode 100644 index 0519ecba..00000000 --- a/app/app/hooks/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/app/index.tsx b/app/app/index.tsx index 649a2e2b..0a302cf4 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -1,600 +1,366 @@ import React, { useRef, useCallback, useEffect, useState } from 'react'; -import { StyleSheet, Text, View, SafeAreaView, ScrollView, Platform, FlatList, ActivityIndicator, Alert, Switch, Button, TouchableOpacity, KeyboardAvoidingView } from 'react-native'; -import { OmiConnection } from 'friend-lite-react-native'; // OmiDevice also comes from here -import { State as BluetoothState } from 'react-native-ble-plx'; // Import State from ble-plx +import { Text, View, SafeAreaView, ScrollView, Platform, FlatList, ActivityIndicator, Alert, Switch, TouchableOpacity, KeyboardAvoidingView, StyleSheet } from 'react-native'; +import { OmiConnection } from 'friend-lite-react-native'; +import { State as BluetoothState } from 'react-native-ble-plx'; +import { Link } from 'expo-router'; +import Constants from 'expo-constants'; +import { useTheme, ThemeColors } from '@/theme'; // Hooks -import { useBluetoothManager } from './hooks/useBluetoothManager'; -import { useDeviceScanning } from './hooks/useDeviceScanning'; -import { useDeviceConnection } from './hooks/useDeviceConnection'; -import { - saveLastConnectedDeviceId, - getLastConnectedDeviceId, - saveWebSocketUrl, - getWebSocketUrl, - saveUserId, - getUserId, - getAuthEmail, - getJwtToken, -} from './utils/storage'; -import { useAudioListener } from './hooks/useAudioListener'; -import { useAudioStreamer } from './hooks/useAudioStreamer'; -import { usePhoneAudioRecorder } from './hooks/usePhoneAudioRecorder'; +import { useBluetoothManager } from '@/hooks/useBluetoothManager'; +import { useDeviceScanning } from '@/hooks/useDeviceScanning'; +import { useDeviceConnection } from '@/hooks/useDeviceConnection'; +import { useSharedAppSettings } from '@/contexts/AppSettingsContext'; +import { useAutoReconnect } from '@/hooks/useAutoReconnect'; +import { useAudioStreamingOrchestrator } from '@/hooks/useAudioStreamingOrchestrator'; +import { useAudioListener } from '@/hooks/useAudioListener'; +import { useAudioStreamer } from '@/hooks/useAudioStreamer'; +import { usePhoneAudioRecorder } from '@/hooks/usePhoneAudioRecorder'; +import { usePhoneAudioDevices } from '@/hooks/usePhoneAudioDevices'; +import { useBatteryMonitor } from '@/hooks/useBatteryMonitor'; +import { saveLastConnectedDeviceId } from '@/utils/storage'; // Components -import BluetoothStatusBanner from './components/BluetoothStatusBanner'; -import ScanControls from './components/ScanControls'; -import DeviceListItem from './components/DeviceListItem'; -import DeviceDetails from './components/DeviceDetails'; -import AuthSection from './components/AuthSection'; -import BackendStatus from './components/BackendStatus'; -import ObsidianIngest from './components/ObsidianIngest'; -import PhoneAudioButton from './components/PhoneAudioButton'; +import BluetoothStatusBanner from '@/components/BluetoothStatusBanner'; +import ScanControls from '@/components/ScanControls'; +import DeviceListItem from '@/components/DeviceListItem'; +import DeviceDetails from '@/components/DeviceDetails'; +import PhoneAudioButton from '@/components/PhoneAudioButton'; +import PhoneAudioMicPicker from '@/components/PhoneAudioMicPicker'; + +// True once the app is pointed at a real backend (not empty and not the +// localhost placeholder a fresh install ships with). Mirrors the +// "not configured" logic in BackendStatus. +function isBackendConfigured(url: string | undefined): boolean { + const trimmed = (url || '').trim(); + if (!trimmed) return false; + try { + const base = trimmed.replace('ws://', 'http://').replace('wss://', 'https://').split('/ws')[0]; + const host = new URL(base).hostname; + return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'; + } catch { + return true; + } +} export default function App() { - // Initialize OmiConnection + const { colors } = useTheme(); + const s = createStyles(colors); const omiConnection = useRef(new OmiConnection()).current; - - // Filter state const [showOnlyOmi, setShowOnlyOmi] = useState(false); + const [activeTab, setActiveTab] = useState<'backend' | 'connection'>('backend'); - // State for remembering the last connected device - const [lastKnownDeviceId, setLastKnownDeviceId] = useState(null); - const [isAttemptingAutoReconnect, setIsAttemptingAutoReconnect] = useState(false); - const [triedAutoReconnectForCurrentId, setTriedAutoReconnectForCurrentId] = useState(false); - - // State for WebSocket URL for custom audio streaming - const [webSocketUrl, setWebSocketUrl] = useState(''); - - // State for User ID - const [userId, setUserId] = useState(''); - - // Authentication state - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [currentUserEmail, setCurrentUserEmail] = useState(null); - const [jwtToken, setJwtToken] = useState(null); - - // Bluetooth Management Hook - const { - bleManager, - bluetoothState, - permissionGranted, - requestBluetoothPermission, - isPermissionsLoading, - } = useBluetoothManager(); - - // Custom Audio Streamer Hook - const audioStreamer = useAudioStreamer(); - - // Phone Audio Recorder Hook - const phoneAudioRecorder = usePhoneAudioRecorder(); - const [isPhoneAudioMode, setIsPhoneAudioMode] = useState(false); + // Bluetooth + const { bleManager, bluetoothState, permissionGranted, requestBluetoothPermission, isPermissionsLoading } = useBluetoothManager(); + // Settings (must be before audioStreamer so the token refresh callback can reference it) + const settings = useSharedAppSettings(); - const { - isListeningAudio: isOmiAudioListenerActive, - audioPacketsReceived, - startAudioListener: originalStartAudioListener, - stopAudioListener: originalStopAudioListener, - isRetrying: isAudioListenerRetrying, - retryAttempts: audioListenerRetryAttempts, - } = useAudioListener( - omiConnection, - () => !!deviceConnection.connectedDeviceId - ); + // Audio + const audioStreamer = useAudioStreamer({ + autoReconnectEnabled: settings.autoReconnectEnabled, + onTokenRefreshed: (newToken) => { + // Update app-level auth state when auto-re-login refreshes the token + if (settings.currentUserEmail) { + settings.handleAuthStatusChange(true, settings.currentUserEmail, newToken); + } + }, + }); + const phoneAudioRecorder = usePhoneAudioRecorder(); + const phoneAudioDevices = usePhoneAudioDevices(); + + const { isListeningAudio: isOmiAudioListenerActive, audioPacketsReceived, startAudioListener: originalStartAudioListener, stopAudioListener: originalStopAudioListener, isRetrying: isAudioListenerRetrying, retryAttempts: audioListenerRetryAttempts } = useAudioListener(omiConnection, () => !!deviceConnection.connectedDeviceId); - // Refs to hold the current state for onDeviceDisconnect without causing re-memoization + // Refs for disconnect cleanup const isOmiAudioListenerActiveRef = useRef(isOmiAudioListenerActive); const isAudioStreamingRef = useRef(audioStreamer.isStreaming); - - useEffect(() => { - isOmiAudioListenerActiveRef.current = isOmiAudioListenerActive; - }, [isOmiAudioListenerActive]); - - useEffect(() => { - isAudioStreamingRef.current = audioStreamer.isStreaming; - }, [audioStreamer.isStreaming]); - - // Now define the stable onDeviceConnect and onDeviceDisconnect callbacks + // Track if audio pipeline was active before BLE disconnect (for auto-restart on reconnect) + const wasStreamingBeforeDisconnectRef = useRef(false); + useEffect(() => { isOmiAudioListenerActiveRef.current = isOmiAudioListenerActive; }, [isOmiAudioListenerActive]); + useEffect(() => { isAudioStreamingRef.current = audioStreamer.isStreaming; }, [audioStreamer.isStreaming]); + + // Refs to break the declaration-order cycle: + // onDeviceConnect/onDeviceDisconnect need orchestrator + autoReconnect, + // but deviceConnection (which needs those callbacks) must be declared + // before orchestrator and autoReconnect. + type OrchestratorHandle = ReturnType; + type AutoReconnectHandle = ReturnType; + const orchestratorRef = useRef(null); + const autoReconnectRef = useRef(null); + + // Device callbacks const onDeviceConnect = useCallback(async () => { - console.log('[App.tsx] Device connected callback.'); - const deviceIdToSave = omiConnection.connectedDeviceId; // Corrected: Use property from OmiConnection instance - + const deviceIdToSave = omiConnection.connectedDeviceId; if (deviceIdToSave) { - console.log('[App.tsx] Saving connected device ID to storage:', deviceIdToSave); await saveLastConnectedDeviceId(deviceIdToSave); - setLastKnownDeviceId(deviceIdToSave); // Update state for consistency - setTriedAutoReconnectForCurrentId(false); // Reset if a new device connects successfully - } else { - console.warn('[App.tsx] onDeviceConnect: Could not determine connected device ID to save. omiConnection.connectedDeviceId was null/undefined.'); + autoReconnectRef.current?.setLastKnownDeviceId(deviceIdToSave); + autoReconnectRef.current?.setTriedAutoReconnectForCurrentId(false); } - // Actions on connect (e.g., auto-fetch codec/battery) - }, [omiConnection]); // saveLastConnectedDeviceId is stable, omiConnection is stable ref - const onDeviceDisconnect = useCallback(async () => { - console.log('[App.tsx] Device disconnected callback.'); - if (isOmiAudioListenerActiveRef.current) { - console.log('[App.tsx] Disconnect: Stopping audio listener.'); - await originalStopAudioListener(); + // Auto-restart audio pipeline if it was active before BLE disconnect + if (wasStreamingBeforeDisconnectRef.current) { + wasStreamingBeforeDisconnectRef.current = false; + console.log('[App] BLE reconnected — auto-restarting audio pipeline'); + // Short delay to let BLE connection stabilize + setTimeout(() => { + orchestratorRef.current?.handleStartAudioListeningAndStreaming().catch(err => { + console.error('[App] Failed to auto-restart audio pipeline:', err); + }); + }, 1000); } - if (isAudioStreamingRef.current) { - console.log('[App.tsx] Disconnect: Stopping custom audio streaming.'); - audioStreamer.stopStreaming(); + }, [omiConnection]); + + const onDeviceDisconnect = useCallback(async () => { + // Remember if audio was active so we can auto-restart on reconnect + if (isOmiAudioListenerActiveRef.current || isAudioStreamingRef.current) { + wasStreamingBeforeDisconnectRef.current = true; } - // Also stop phone audio if it's running + + // Stop audio listener (BLE is gone, can't read audio) + if (isOmiAudioListenerActiveRef.current) await originalStopAudioListener(); + + // Keep WebSocket alive — it will reconnect or idle until BLE comes back. + // Only stop WebSocket for phone audio mode (no BLE needed there). if (phoneAudioRecorder.isRecording) { - console.log('[App.tsx] Disconnect: Stopping phone audio recording.'); + audioStreamer.stopStreaming(); await phoneAudioRecorder.stopRecording(); - setIsPhoneAudioMode(false); + orchestratorRef.current?.setIsPhoneAudioMode(false); } - }, [originalStopAudioListener, audioStreamer.stopStreaming, phoneAudioRecorder.stopRecording, phoneAudioRecorder.isRecording, setIsPhoneAudioMode]); - - // Initialize Device Connection hook, passing the memoized callbacks - const deviceConnection = useDeviceConnection( - omiConnection, - onDeviceDisconnect, - onDeviceConnect - ); - - // Effect to load settings on app startup - useEffect(() => { - const loadSettings = async () => { - const deviceId = await getLastConnectedDeviceId(); - if (deviceId) { - console.log('[App.tsx] Loaded last known device ID from storage:', deviceId); - setLastKnownDeviceId(deviceId); - setTriedAutoReconnectForCurrentId(false); - } else { - console.log('[App.tsx] No last known device ID found in storage. Auto-reconnect will not be attempted.'); - setLastKnownDeviceId(null); // Explicitly ensure it's null - setTriedAutoReconnectForCurrentId(true); // Mark that we shouldn't try (as no ID is known) - } - - const storedWsUrl = await getWebSocketUrl(); - if (storedWsUrl) { - console.log('[App.tsx] Loaded WebSocket URL from storage:', storedWsUrl); - setWebSocketUrl(storedWsUrl); - } else { - // Set default to simple backend - const defaultUrl = 'ws://localhost:8000/ws'; - console.log('[App.tsx] No stored WebSocket URL, setting default for simple backend:', defaultUrl); - setWebSocketUrl(defaultUrl); - await saveWebSocketUrl(defaultUrl); - } + }, [originalStopAudioListener, audioStreamer.stopStreaming, phoneAudioRecorder.stopRecording, phoneAudioRecorder.isRecording]); - const storedUserId = await getUserId(); - if (storedUserId) { - console.log('[App.tsx] Loaded User ID from storage:', storedUserId); - setUserId(storedUserId); - } - - // Load authentication data - const storedEmail = await getAuthEmail(); - const storedToken = await getJwtToken(); - if (storedEmail && storedToken) { - console.log('[App.tsx] Loaded auth data from storage for:', storedEmail); - setCurrentUserEmail(storedEmail); - setJwtToken(storedToken); - setIsAuthenticated(true); - } - }; - loadSettings(); - }, []); + const deviceConnection = useDeviceConnection(omiConnection, onDeviceDisconnect, onDeviceConnect); + // Battery monitor + const batteryMonitor = useBatteryMonitor({ + connectedDeviceId: deviceConnection.connectedDeviceId, + getBatteryLevel: deviceConnection.getRawBatteryLevel, + onConnectionLost: deviceConnection.disconnectFromDevice, + }); - // Device Scanning Hook - const { - devices: scannedDevices, - scanning, - startScan, - stopScan: stopDeviceScanAction, - } = useDeviceScanning( - bleManager, // From useBluetoothManager - omiConnection, - permissionGranted, // From useBluetoothManager - bluetoothState === BluetoothState.PoweredOn, // Derived from useBluetoothManager - requestBluetoothPermission // From useBluetoothManager, should be stable - ); - - // Effect for attempting auto-reconnection - useEffect(() => { - if ( - bluetoothState === BluetoothState.PoweredOn && - permissionGranted && - lastKnownDeviceId && - !deviceConnection.connectedDeviceId && // Only if not already connected - !deviceConnection.isConnecting && // Only if not currently trying to connect by other means - !scanning && // Only if not currently scanning - !isAttemptingAutoReconnect && // Only if not already attempting auto-reconnect - !triedAutoReconnectForCurrentId // Only try once per loaded/set lastKnownDeviceId - ) { - const attemptAutoConnect = async () => { - console.log(`[App.tsx] Attempting to auto-reconnect to device: ${lastKnownDeviceId}`); - setIsAttemptingAutoReconnect(true); - setTriedAutoReconnectForCurrentId(true); // Mark that we've initiated an attempt for this ID - try { - // useDeviceConnection.connectToDevice can take a device ID string directly - await deviceConnection.connectToDevice(lastKnownDeviceId); - // If connectToDevice throws, catch block handles it. - // If it resolves, the connection attempt was made. - // The onDeviceConnect callback will be triggered if successful. - console.log(`[App.tsx] Auto-reconnect attempt initiated for ${lastKnownDeviceId}. Waiting for connection event.`); - // Removed the if(success) block as connectToDevice is void - } catch (error) { - console.error(`[App.tsx] Error auto-reconnecting to ${lastKnownDeviceId}:`, error); - // Clear the problematic device ID from storage and state - if (lastKnownDeviceId) { // Ensure we have an ID to clear - console.log(`[App.tsx] Clearing problematic device ID ${lastKnownDeviceId} from storage due to auto-reconnect failure.`); - await saveLastConnectedDeviceId(null); // Clears from AsyncStorage - setLastKnownDeviceId(null); // Clears from current app state - } - } finally { - setIsAttemptingAutoReconnect(false); - } - }; - attemptAutoConnect(); - } - }, [ + // Auto-reconnect + const autoReconnect = useAutoReconnect({ bluetoothState, permissionGranted, - lastKnownDeviceId, - deviceConnection.connectedDeviceId, - deviceConnection.isConnecting, - scanning, - deviceConnection.connectToDevice, // Stable function from the hook - triedAutoReconnectForCurrentId, - isAttemptingAutoReconnect, // Added to prevent re-triggering while one is in progress - // Added saveLastConnectedDeviceId and setLastKnownDeviceId to dependency array if they were not already implicitly covered - // saveLastConnectedDeviceId is an import, setLastKnownDeviceId is a state setter - typically stable - ]); - - const handleStartAudioListeningAndStreaming = useCallback(async () => { - if (!webSocketUrl || webSocketUrl.trim() === '') { - Alert.alert('WebSocket URL Required', 'Please enter the WebSocket URL for streaming.'); - return; - } - if (!omiConnection.isConnected() || !deviceConnection.connectedDeviceId) { - Alert.alert('Device Not Connected', 'Please connect to an OMI device first.'); - return; - } - - try { - let finalWebSocketUrl = webSocketUrl.trim(); - - // Check if this is the advanced backend (requires authentication) or simple backend - const isAdvancedBackend = jwtToken && isAuthenticated; - - if (isAdvancedBackend) { - // Advanced backend: include JWT token and device parameters - const params = new URLSearchParams(); - params.append('token', jwtToken); - - if (userId && userId.trim() !== '') { - params.append('device_name', userId.trim()); - console.log('[App.tsx] Using advanced backend with token and device_name:', userId.trim()); - } else { - params.append('device_name', 'phone'); // Default device name - console.log('[App.tsx] Using advanced backend with token and default device_name'); - } - - const separator = webSocketUrl.includes('?') ? '&' : '?'; - finalWebSocketUrl = `${webSocketUrl}${separator}${params.toString()}`; - console.log('[App.tsx] Advanced backend WebSocket URL constructed (token hidden for security)'); - } else { - // Simple backend: use URL as-is without authentication - console.log('[App.tsx] Using simple backend without authentication:', finalWebSocketUrl); - } - - // Start custom WebSocket streaming first - await audioStreamer.startStreaming(finalWebSocketUrl); - - // Then start OMI audio listener - await originalStartAudioListener(async (audioBytes) => { - const wsReadyState = audioStreamer.getWebSocketReadyState(); - if (wsReadyState === WebSocket.OPEN && audioBytes.length > 0) { - await audioStreamer.sendAudio(audioBytes); - } - }); - } catch (error) { - console.error('[App.tsx] Error starting audio listening/streaming:', error); - Alert.alert('Error', 'Could not start audio listening or streaming.'); - // Ensure cleanup if one part started but the other failed - if (audioStreamer.isStreaming) audioStreamer.stopStreaming(); - } - }, [originalStartAudioListener, audioStreamer, webSocketUrl, userId, omiConnection, deviceConnection.connectedDeviceId, jwtToken, isAuthenticated]); - - const handleStopAudioListeningAndStreaming = useCallback(async () => { - console.log('[App.tsx] Stopping audio listening and streaming.'); - await originalStopAudioListener(); - audioStreamer.stopStreaming(); - }, [originalStopAudioListener, audioStreamer]); - - // Phone Audio Streaming Functions - const handleStartPhoneAudioStreaming = useCallback(async () => { - if (!webSocketUrl || webSocketUrl.trim() === '') { - Alert.alert('WebSocket URL Required', 'Please enter the WebSocket URL for streaming.'); - return; - } - - try { - let finalWebSocketUrl = webSocketUrl.trim(); - - // Convert HTTP/HTTPS to WS/WSS protocol - finalWebSocketUrl = finalWebSocketUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'); - - // Ensure /ws endpoint is included - if (!finalWebSocketUrl.includes('/ws')) { - // Remove trailing slash if present, then add /ws - finalWebSocketUrl = finalWebSocketUrl.replace(/\/$/, '') + '/ws'; - } - - // Add codec parameter if not present - if (!finalWebSocketUrl.includes('codec=')) { - const separator = finalWebSocketUrl.includes('?') ? '&' : '?'; - finalWebSocketUrl = finalWebSocketUrl + separator + 'codec=pcm'; - } - - // Check if this is the advanced backend (requires authentication) or simple backend - const isAdvancedBackend = jwtToken && isAuthenticated; - - if (isAdvancedBackend) { - // Advanced backend: include JWT token and device parameters - const params = new URLSearchParams(); - params.append('token', jwtToken); - - const deviceName = userId && userId.trim() !== '' ? userId.trim() : 'phone-mic'; - params.append('device_name', deviceName); - console.log('[App.tsx] Using advanced backend with token and device_name:', deviceName); - - const separator = finalWebSocketUrl.includes('?') ? '&' : '?'; - finalWebSocketUrl = `${finalWebSocketUrl}${separator}${params.toString()}`; - console.log('[App.tsx] Advanced backend WebSocket URL constructed for phone audio'); - } else { - // Simple backend: use URL as-is without authentication - console.log('[App.tsx] Using simple backend without authentication for phone audio'); - } + deviceConnection, + scanning: false, + autoReconnectEnabled: settings.autoReconnectEnabled, + }); - // Start WebSocket streaming first - await audioStreamer.startStreaming(finalWebSocketUrl); - - // Start phone audio recording - await phoneAudioRecorder.startRecording(async (pcmBuffer) => { - const wsReadyState = audioStreamer.getWebSocketReadyState(); - if (wsReadyState === WebSocket.OPEN && pcmBuffer.length > 0) { - await audioStreamer.sendAudio(pcmBuffer); - } - }); - - setIsPhoneAudioMode(true); - console.log('[App.tsx] Phone audio streaming started successfully'); - } catch (error) { - console.error('[App.tsx] Error starting phone audio streaming:', error); - Alert.alert('Error', 'Could not start phone audio streaming.'); - // Ensure cleanup if one part started but the other failed - if (audioStreamer.isStreaming) audioStreamer.stopStreaming(); - if (phoneAudioRecorder.isRecording) await phoneAudioRecorder.stopRecording(); - setIsPhoneAudioMode(false); - } - }, [audioStreamer, phoneAudioRecorder, webSocketUrl, userId, jwtToken, isAuthenticated]); - - const handleStopPhoneAudioStreaming = useCallback(async () => { - console.log('[App.tsx] Stopping phone audio streaming.'); - await phoneAudioRecorder.stopRecording(); - audioStreamer.stopStreaming(); - setIsPhoneAudioMode(false); - }, [phoneAudioRecorder, audioStreamer]); - - const handleTogglePhoneAudio = useCallback(async () => { - if (isPhoneAudioMode || phoneAudioRecorder.isRecording) { - await handleStopPhoneAudioStreaming(); - } else { - await handleStartPhoneAudioStreaming(); - } - }, [isPhoneAudioMode, phoneAudioRecorder.isRecording, handleStartPhoneAudioStreaming, handleStopPhoneAudioStreaming]); + // Scanning + const { devices: scannedDevices, scanning, startScan, stopScan: stopDeviceScanAction } = useDeviceScanning(bleManager, omiConnection, permissionGranted, bluetoothState === BluetoothState.PoweredOn, requestBluetoothPermission); - // Store stable references for cleanup - const cleanupRefs = useRef({ + // Audio orchestrator + const orchestrator = useAudioStreamingOrchestrator({ omiConnection, - bleManager, - disconnectFromDevice: deviceConnection.disconnectFromDevice, - stopAudioStreaming: audioStreamer.stopStreaming, - stopPhoneAudio: phoneAudioRecorder.stopRecording, + deviceConnection, + audioStreamer, + phoneAudioRecorder, + originalStartAudioListener, + originalStopAudioListener, + resolvePhoneInputDeviceId: phoneAudioDevices.resolveEffectiveDeviceId, + settings, }); - // Update refs when functions change - useEffect(() => { - cleanupRefs.current = { - omiConnection, - bleManager, - disconnectFromDevice: deviceConnection.disconnectFromDevice, - stopAudioStreaming: audioStreamer.stopStreaming, - stopPhoneAudio: phoneAudioRecorder.stopRecording, - }; - }); + // Keep forward-declared refs in sync so device callbacks can call through. + orchestratorRef.current = orchestrator; + autoReconnectRef.current = autoReconnect; - // Cleanup only on actual unmount (no dependencies to avoid re-runs) + // Cleanup + const cleanupRefs = useRef({ omiConnection, bleManager, disconnectFromDevice: deviceConnection.disconnectFromDevice, stopAudioStreaming: audioStreamer.stopStreaming, stopPhoneAudio: phoneAudioRecorder.stopRecording }); + useEffect(() => { cleanupRefs.current = { omiConnection, bleManager, disconnectFromDevice: deviceConnection.disconnectFromDevice, stopAudioStreaming: audioStreamer.stopStreaming, stopPhoneAudio: phoneAudioRecorder.stopRecording }; }); useEffect(() => { return () => { - console.log('App unmounting - cleaning up OmiConnection, BleManager, AudioStreamer, and PhoneAudioRecorder'); const refs = cleanupRefs.current; - - if (refs.omiConnection.isConnected()) { - refs.disconnectFromDevice().catch(err => console.error("Error disconnecting in cleanup:", err)); - } - if (refs.bleManager) { - refs.bleManager.destroy(); - } + if (refs.omiConnection.isConnected()) refs.disconnectFromDevice().catch(() => {}); + if (refs.bleManager) refs.bleManager.destroy(); refs.stopAudioStreaming(); - // Phone audio stopRecording now handles inactive state gracefully - refs.stopPhoneAudio().catch(err => console.error("Error stopping phone audio in cleanup:", err)); + refs.stopPhoneAudio().catch(() => {}); }; - }, []); // Empty dependency array - only run on mount/unmount + }, []); const canScan = React.useMemo(() => ( - permissionGranted && - bluetoothState === BluetoothState.PoweredOn && - !isAttemptingAutoReconnect && + permissionGranted && bluetoothState === BluetoothState.PoweredOn && + !autoReconnect.isAttemptingAutoReconnect && !autoReconnect.isRetryingConnection && !deviceConnection.isConnecting && !deviceConnection.connectedDeviceId && - (triedAutoReconnectForCurrentId || !lastKnownDeviceId) - // Removed authentication requirement for scanning - ), [ - permissionGranted, - bluetoothState, - isAttemptingAutoReconnect, - deviceConnection.isConnecting, - deviceConnection.connectedDeviceId, - triedAutoReconnectForCurrentId, - lastKnownDeviceId, - ]); + (autoReconnect.triedAutoReconnectForCurrentId || !autoReconnect.lastKnownDeviceId) + ), [permissionGranted, bluetoothState, autoReconnect.isAttemptingAutoReconnect, autoReconnect.isRetryingConnection, deviceConnection.isConnecting, deviceConnection.connectedDeviceId, autoReconnect.triedAutoReconnectForCurrentId, autoReconnect.lastKnownDeviceId]); const filteredDevices = React.useMemo(() => { - if (!showOnlyOmi) { - return scannedDevices; - } - return scannedDevices.filter(device => { - const name = device.name?.toLowerCase() || ''; - return name.includes('omi') || name.includes('friend'); + if (!showOnlyOmi) return scannedDevices; + return scannedDevices.filter(d => { + const name = d.name?.toLowerCase() || ''; + return name.includes('omi') || name.includes('friend') || name.includes('neo') || name.includes('elato'); }); }, [scannedDevices, showOnlyOmi]); - const handleSetAndSaveWebSocketUrl = useCallback(async (url: string) => { - setWebSocketUrl(url); - await saveWebSocketUrl(url); - }, []); - - const handleSetAndSaveUserId = useCallback(async (id: string) => { - setUserId(id); - await saveUserId(id || null); - }, []); - - // Authentication status change handler - const handleAuthStatusChange = useCallback((authenticated: boolean, email: string | null, token: string | null) => { - setIsAuthenticated(authenticated); - setCurrentUserEmail(email); - setJwtToken(token); - console.log('[App.tsx] Auth status changed:', { authenticated, email: email ? 'logged in' : 'logged out' }); - }, []); - - const handleCancelAutoReconnect = useCallback(async () => { - console.log('[App.tsx] Cancelling auto-reconnection attempt.'); - if (lastKnownDeviceId) { - // Clear the last known device ID to prevent further auto-reconnect attempts in this session - await saveLastConnectedDeviceId(null); - setLastKnownDeviceId(null); - setTriedAutoReconnectForCurrentId(true); // Mark as tried to prevent immediate re-trigger if conditions meet again - } - // Attempt to stop any ongoing connection process - // disconnectFromDevice also sets isConnecting to false internally. - await deviceConnection.disconnectFromDevice(); - setIsAttemptingAutoReconnect(false); // Explicitly set to false to hide the auto-reconnect screen - }, [deviceConnection, lastKnownDeviceId, saveLastConnectedDeviceId, setLastKnownDeviceId, setTriedAutoReconnectForCurrentId, setIsAttemptingAutoReconnect]); - + const bluetoothReady = bluetoothState === BluetoothState.PoweredOn && permissionGranted; + // A fresh install points at localhost (the phone itself), which can never be a + // real backend — treat that (and empty) as "not paired yet" so the setup card + // and health pill reflect reality. + const backendConfigured = isBackendConfigured(settings.webSocketUrl); + const isOperational = bluetoothReady && backendConfigured; + const healthLabel = isOperational ? 'System Operational' : 'Action Needed'; + const healthTone = isOperational ? colors.success : colors.warning; + const batteryDisplay = deviceConnection.connectedDeviceId + ? batteryMonitor.batteryLevel >= 0 ? `${batteryMonitor.batteryLevel}%` : '...' + : '--'; + const streamDisplay = audioStreamer.isStreaming + ? 'Streaming' + : (phoneAudioRecorder.isRecording || orchestrator.isPhoneAudioMode) + ? 'Phone Mic' + : 'Idle'; + + // Loading / auto-reconnect screens if (isPermissionsLoading && bluetoothState === BluetoothState.Unknown) { return ( - - - - {isAttemptingAutoReconnect - ? `Attempting to reconnect to the last device (${lastKnownDeviceId ? lastKnownDeviceId.substring(0, 10) + '...' : ''})...` + + + + {autoReconnect.isAttemptingAutoReconnect + ? `Reconnecting to ${autoReconnect.lastKnownDeviceId?.substring(0, 10)}...` : 'Initializing Bluetooth...'} ); } - if (isAttemptingAutoReconnect) { - return ( - - - - - Attempting to reconnect to the last device ({lastKnownDeviceId ? lastKnownDeviceId.substring(0, 10) + '...' : ''})... - - + + + +
+ {vrError ? ( + VR: {vrError} + ) : error ? ( + {error} + ) : ( + `${nodeCount} notes · ${linkCount} links` + )} +
+ +
+ +
Transition
+
+ {(["smooth", "snap"] as const).map((m) => ( + + ))} +
+ + + + + + {focusNode && ( +
+ focused: {focusNode.name} +
+ )} + +
+
+ Trigger a node: open it · Link chip: fly to that note · Hold B on a node: move it · + Double-tap B: anchor · L-stick: fly · R-stick: up/turn · A: passthrough · X: exit · Y: debug · L-stick-click: 360. +
+ + ); +} diff --git a/extras/vault-graph-vr/src/config.ts b/extras/vault-graph-vr/src/config.ts new file mode 100644 index 00000000..b8ebd739 --- /dev/null +++ b/extras/vault-graph-vr/src/config.ts @@ -0,0 +1,32 @@ +export type TransitionMode = "smooth" | "snap"; + +export interface TransitionConfig { + mode: TransitionMode; + /** Seconds for the smooth glide to (mostly) settle. Ignored for "snap". */ + durationSec: number; + /** How far in front of you the focused node ends up, in metres. */ + viewDistance: number; +} + +export const DEFAULT_TRANSITION: TransitionConfig = { + mode: "smooth", + durationSec: 0.9, + viewDistance: 2.4, +}; + +/** World metres per force-sim unit. Keeps a ~100-unit graph to ~5 m. */ +export const GRAPH_SCALE = 0.06; + +/** + * Where the graph's centre floats, in metres (x, y up, z forward is negative). + * Centred on the player's head height so you START INSIDE the graph (full 360), + * rather than looking at it as a cluster floating in front of you. + */ +export const GRAPH_POSITION: [number, number, number] = [0, 1.5, 0]; + +/** Free-flight speed (m/s) and smooth-turn speed (rad/s) for the thumbsticks. */ +export const MOVE_SPEED = 4; +export const TURN_SPEED = 1.5; + +/** Thumbstick deadzone. */ +export const DEADZONE = 0.15; diff --git a/extras/vault-graph-vr/src/errorRelay.ts b/extras/vault-graph-vr/src/errorRelay.ts new file mode 100644 index 00000000..8a259ce2 --- /dev/null +++ b/extras/vault-graph-vr/src/errorRelay.ts @@ -0,0 +1,62 @@ +// Relays browser logs/errors to the dev server so they can be read outside the +// headset. Critically, it wraps console.error so errors that are otherwise +// swallowed by `.catch(console.error)` (as @react-three/xr does for controller +// layout loading) become visible in ./captures/debug.log. + +function stringify(args: unknown[]): string { + return args + .map((a) => { + if (a instanceof Error) return a.stack || `${a.name}: ${a.message}`; + if (typeof a === "object" && a !== null) { + try { + return JSON.stringify(a); + } catch { + return String(a); + } + } + return String(a); + }) + .join(" "); +} + +function post(level: string, args: unknown[]) { + try { + fetch("/__log", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: `[${level}] ${stringify(args)}`, + keepalive: true, + }).catch(() => {}); + } catch { + /* ignore */ + } +} + +let installed = false; + +export function installErrorRelay() { + if (installed) return; + installed = true; + + const origError = console.error.bind(console); + console.error = (...args: unknown[]) => { + post("error", args); + origError(...args); + }; + + const origWarn = console.warn.bind(console); + console.warn = (...args: unknown[]) => { + post("warn", args); + origWarn(...args); + }; + + window.addEventListener("error", (e) => { + post("window.error", [e.message, `${e.filename}:${e.lineno}:${e.colno}`, e.error]); + }); + + window.addEventListener("unhandledrejection", (e) => { + post("unhandledrejection", [e.reason]); + }); + + post("info", ["error relay installed"]); +} diff --git a/extras/vault-graph-vr/src/graph.ts b/extras/vault-graph-vr/src/graph.ts new file mode 100644 index 00000000..bfdfdceb --- /dev/null +++ b/extras/vault-graph-vr/src/graph.ts @@ -0,0 +1,32 @@ +import type { GraphData, GraphNode } from "./types"; + +function hashHue(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; + return h % 360; +} + +/** Node colour: notes coloured by folder, unresolved/dir/file have fixed hues. */ +export function nodeColor(n: GraphNode): string { + switch (n.type) { + case "unresolved": + return "#4a5160"; + case "dir": + return "#ffcf6b"; + case "file": + return "#7fd1b9"; + default: + return `hsl(${hashHue(n.folder || "root")}, 65%, 62%)`; + } +} + +/** Bigger nodes = more connected. */ +export function nodeVal(n: GraphNode): number { + return 1 + (n.degree || 0); +} + +export async function loadGraph(url = "graph.json"): Promise { + const res = await fetch(url, { cache: "no-cache" }); + if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`); + return (await res.json()) as GraphData; +} diff --git a/extras/vault-graph-vr/src/main.tsx b/extras/vault-graph-vr/src/main.tsx new file mode 100644 index 00000000..1934dd03 --- /dev/null +++ b/extras/vault-graph-vr/src/main.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import { installErrorRelay } from "./errorRelay"; + +// Pipe console.error/warn, uncaught errors and unhandled rejections to the dev +// server (/__log → ./captures/debug.log) so swallowed errors (e.g. the XR +// controller-layout load that fails via `.catch(console.error)`) are visible. +installErrorRelay(); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/extras/vault-graph-vr/src/types.ts b/extras/vault-graph-vr/src/types.ts new file mode 100644 index 00000000..8efd4809 --- /dev/null +++ b/extras/vault-graph-vr/src/types.ts @@ -0,0 +1,32 @@ +export type NodeType = "note" | "unresolved" | "dir" | "file"; + +export interface GraphNode { + id: string; + name: string; + type: NodeType; + path?: string; + folder?: string; + content?: string; + ext?: string; + degree?: number; + // injected by the force simulation at runtime: + x?: number; + y?: number; + z?: number; + // d3-force fixed-position pins (set to pin/drag a node, undefined to release): + fx?: number; + fy?: number; + fz?: number; +} + +export interface GraphLink { + // strings before the sim runs, node refs after r3f-forcegraph processes them: + source: string | GraphNode; + target: string | GraphNode; + kind?: string; +} + +export interface GraphData { + nodes: GraphNode[]; + links: GraphLink[]; +} diff --git a/extras/vault-graph-vr/tsconfig.json b/extras/vault-graph-vr/tsconfig.json new file mode 100644 index 00000000..a4c834a6 --- /dev/null +++ b/extras/vault-graph-vr/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/extras/vault-graph-vr/vite.config.ts b/extras/vault-graph-vr/vite.config.ts new file mode 100644 index 00000000..d8e14e48 --- /dev/null +++ b/extras/vault-graph-vr/vite.config.ts @@ -0,0 +1,72 @@ +import { defineConfig, type Plugin } from "vite"; +import react from "@vitejs/plugin-react"; +import fs from "node:fs"; +import path from "node:path"; + +// Receives a PNG (base64 data URL) from the in-headset capture button and +// writes it to ./captures so it can be inspected outside the headset. +function captureEndpoint(): Plugin { + return { + name: "vault-graph-vr-capture", + configureServer(server) { + // Browser log/error relay → ./captures/debug.log + terminal. + server.middlewares.use("/__log", (req, res) => { + if (req.method !== "POST") { + res.statusCode = 405; + return res.end(); + } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + try { + const dir = path.resolve(process.cwd(), "captures"); + fs.mkdirSync(dir, { recursive: true }); + const line = `${new Date().toISOString()} ${body}\n`; + fs.appendFileSync(path.join(dir, "debug.log"), line); + server.config.logger.info(`[app] ${body}`); + } catch { + /* ignore */ + } + res.end("ok"); + }); + }); + + server.middlewares.use("/__capture", (req, res) => { + if (req.method !== "POST") { + res.statusCode = 405; + return res.end(); + } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + try { + const { dataUrl } = JSON.parse(body); + const b64 = String(dataUrl).replace(/^data:image\/png;base64,/, ""); + const dir = path.resolve(process.cwd(), "captures"); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `view-${Date.now()}.png`); + fs.writeFileSync(file, Buffer.from(b64, "base64")); + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ ok: true, file })); + server.config.logger.info(`[capture] wrote ${file}`); + } catch (e) { + res.statusCode = 500; + res.end(String(e)); + } + }); + }); + }, + }; +} + +// Dev server binds localhost by default, which is a WebXR "secure context" +// when reached through `adb reverse tcp:5180 tcp:5180` from the Quest. +// Use `npm run host` to expose on the LAN instead (then you need HTTPS for WebXR). +export default defineConfig({ + plugins: [react(), captureEndpoint()], + server: { + // 5180 to avoid clashing with the friend-lite webui dev server (5173). + port: 5180, + strictPort: true, + }, +}); diff --git a/extras/vault-sync/.env.template b/extras/vault-sync/.env.template new file mode 100644 index 00000000..371bc2f2 --- /dev/null +++ b/extras/vault-sync/.env.template @@ -0,0 +1,19 @@ +# Chronicle Vault Sync — Mac configuration +# Copy to .env and fill in. Then: ./start.sh + +# Backend URL. If you run Tailscale you can leave this blank and it will be +# auto-discovered; otherwise set your server's address explicitly. +# BACKEND_URL=https://my-host.ts.net + +# Chronicle login (same account you use for the web dashboard). +AUTH_USERNAME=admin@example.com +AUTH_PASSWORD= + +# Where the vault is synced on this Mac (you can also pick it from the menu). +LOCAL_VAULT_DIR=~/ChronicleVault + +# Optional: how this device appears on the server (defaults to the Mac hostname). +# DEVICE_NAME=my-macbook + +# Optional: local Syncthing REST port (default 8385; change if it clashes). +# VAULT_SYNC_GUI_PORT=8385 diff --git a/extras/vault-sync/.gitignore b/extras/vault-sync/.gitignore new file mode 100644 index 00000000..15753524 --- /dev/null +++ b/extras/vault-sync/.gitignore @@ -0,0 +1,5 @@ +.env +.venv/ +__pycache__/ +*.pyc +uv.lock diff --git a/extras/vault-sync/README.md b/extras/vault-sync/README.md new file mode 100644 index 00000000..46610d82 --- /dev/null +++ b/extras/vault-sync/README.md @@ -0,0 +1,113 @@ +# Chronicle Vault Sync (macOS) + +A menu bar app that keeps your Chronicle Obsidian vault +(`data/conversation_docs/{your_user}` on the server) synced to a folder on your Mac, so +you can open it in **Obsidian** with full backlinks, graph view, etc. + +Under the hood it runs a private, headless [Syncthing](https://syncthing.net) and pairs +it with the server automatically through the Chronicle backend — you never touch the +Syncthing UI. It's a sibling of the other menu bar apps (`havpe-relay`, +`local-wearable-client`): same `rumps` + `uv` + launchd pattern. + +``` +Server Syncthing ◀──── sync protocol :22000 (over Tailscale) ────▶ Mac Syncthing + (data/conversation_docs/{user}) (~/ChronicleVault) + ▲ │ + │ pairing brokered by backend /api/vault-sync ▼ + └─────────────── Mac authenticates with its JWT ─────────── Obsidian +``` + +## Prerequisites (Mac) + +```bash +brew install syncthing # the sync engine +# uv (if you don't have it): curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +You also need the Chronicle **server** side running with vault sync enabled — see +[Server setup](#server-setup-once) below. + +## Setup (Mac) + +```bash +cd extras/vault-sync +cp .env.template .env +# edit .env: set AUTH_USERNAME (email), AUTH_PASSWORD, and BACKEND_URL + +./start.sh # run in the foreground (a ◈ icon appears in the menu bar) +``` + +The Mac needs only three things: `BACKEND_URL`, `AUTH_USERNAME` (your Chronicle email), +and `AUTH_PASSWORD`. The pairing broker hands back everything else (server device id, +sync address) — you never set `VAULT_SYNC_*` on the Mac; those are server-only. + +> **macOS + Tailscale: set `BACKEND_URL` explicitly.** Auto-discovery (minidisc) needs +> the `tailscaled` unix socket at `/var/run/tailscale/tailscaled.sock`, which the **macOS +> GUI / App-Store Tailscale app does not expose** (it runs as a sandboxed network +> extension). So on a Mac, discovery returns nothing and the app would fall back to +> `localhost`. Just point at your server's MagicDNS name — that *is* discovery for a +> fixed server, and needs no socket: +> ```bash +> BACKEND_URL=https://.ts.net +> ``` +> Confirm with `test -S /var/run/tailscale/tailscaled.sock && echo present || echo absent` +> (absent is normal). The app logs exactly which path it took — see **View Logs**. + +On launch it starts Syncthing, authenticates to Chronicle, pairs, and begins syncing +into `~/ChronicleVault` (or `LOCAL_VAULT_DIR`). From the menu: + +- **Open in Obsidian** — opens the synced folder as a vault +- **Choose Vault Folder…** — pick a different local folder (re-pairs automatically) +- **Sync Now / Re-pair** — re-run the handshake +- **View Logs** — recent activity + +### Run it as a login item (always on) + +```bash +./start.sh install # installs a launchd agent + "Chronicle Vault Sync.app" +./start.sh status +./start.sh logs +./start.sh uninstall +``` + +## Server setup (once) + +On the machine running the advanced backend: + +1. Add a strong key and your Tailscale sync address to `backends/advanced/.env`: + ```bash + VAULT_SYNC_API_KEY= + VAULT_SYNC_ADDRESS=tcp://.ts.net:22000 # what the Mac dials; port 22000 + ``` +2. Start the Syncthing service (it's behind a compose profile) and (re)create the + backend so it picks up the new env + the broker route: + ```bash + cd backends/advanced + docker compose --profile vault-sync up -d vault-syncthing + docker compose up -d --force-recreate chronicle-backend + ``` + Make sure port **22000** is reachable from your Mac (it is over Tailscale). +3. Verify the broker chain (should return a `server_device_id` + your `sync_address`): + ```bash + TOKEN=$(curl -s -X POST -d "username=&password=" \ + http://localhost:8000/auth/jwt/login | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') + curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/vault-sync/info + ``` + +The backend's `/api/vault-sync` broker configures the server Syncthing for you when the +Mac pairs — no manual Syncthing setup on the server either. + +> **Use `--force-recreate`, not `restart`.** The backend bind-mounts `discovery.py` as a +> single file; editing it changes the inode and a plain `docker compose restart` fails to +> re-establish the mount (especially on Docker Desktop / WSL). `up -d --force-recreate` +> recreates the container with fresh mounts. A recreate is also what injects newly added +> `.env` values into the container's environment. + +## Notes + +- **Conflicts**: Syncthing keeps both sides; simultaneous edits to the same note from + the server (AI) and Obsidian produce a `*.sync-conflict-*.md` file rather than losing + data. In practice the AI mostly creates/appends and you mostly curate, so this is rare. +- **Multiple Macs**: pair each one; they all share the same server folder. +- The local Syncthing uses its own home dir and GUI port (`8385` by default), so it + won't interfere with any Syncthing you already run. diff --git a/extras/vault-sync/main.py b/extras/vault-sync/main.py new file mode 100644 index 00000000..4afda152 --- /dev/null +++ b/extras/vault-sync/main.py @@ -0,0 +1,41 @@ +"""Chronicle Vault Sync — entry point with service-management subcommands.""" + +import argparse + +from service import install, kickstart, logs, status, uninstall + +_COMMANDS = ("menu", "install", "uninstall", "kickstart", "status", "logs") + + +def cli() -> None: + parser = argparse.ArgumentParser(description="Chronicle Vault Sync") + sub = parser.add_subparsers(dest="command") + sub.add_parser("menu", help="Launch the menu bar app (default)") + sub.add_parser("install", help="Install as a macOS login item") + sub.add_parser("uninstall", help="Remove the macOS login item") + sub.add_parser("kickstart", help="Relaunch the menu bar app") + sub.add_parser("status", help="Show service status") + sub.add_parser("logs", help="Tail service logs") + + args = parser.parse_args() + command = args.command or "menu" + + if command == "menu": + # Lazy import: macOS-only (rumps, darwin-only per pyproject.toml) + from menu_vault import main as menu_main + + menu_main() + elif command == "install": + install() + elif command == "uninstall": + uninstall() + elif command == "kickstart": + kickstart() + elif command == "status": + status() + elif command == "logs": + logs() + + +if __name__ == "__main__": + cli() diff --git a/extras/vault-sync/menu_vault.py b/extras/vault-sync/menu_vault.py new file mode 100644 index 00000000..eb4b2786 --- /dev/null +++ b/extras/vault-sync/menu_vault.py @@ -0,0 +1,375 @@ +"""macOS menu bar app for Chronicle vault sync. + +Owns a local headless Syncthing, pairs it with the Chronicle server via the backend +broker, and keeps the user's Obsidian vault folder in sync. No terminal or Syncthing +UI needed — just pick a folder and open it in Obsidian. +""" + +import logging +import subprocess +import threading +from collections import deque +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional +from urllib.parse import quote + +import httpx +import rumps +from dotenv import load_dotenv +from syncthing_manager import SyncthingManager +from vault_core import VaultSyncConfig, broker_pair, get_jwt_token, save_vault_dir + +logger = logging.getLogger(__name__) + +load_dotenv() + + +class MemoryLogHandler(logging.Handler): + """Keep recent log lines in memory for display in the menu bar UI.""" + + def __init__(self, capacity: int = 500) -> None: + super().__init__() + self.lines: deque = deque(maxlen=capacity) + + def emit(self, record: logging.LogRecord) -> None: + try: + self.lines.append(self.format(record)) + except Exception: + self.handleError(record) + + +log_buffer = MemoryLogHandler() + + +def _show_logs_dialog(title: str, lines) -> None: + """Show log lines in a scrollable modal dialog.""" + # Lazy import: macOS-only (AppKit/Foundation, not available cross-platform) + from AppKit import ( + NSAlert, + NSBezelBorder, + NSFont, + NSScrollView, + NSTextView, + NSViewWidthSizable, + ) + from Foundation import NSMakeRect, NSMakeSize + + text = "\n".join(lines) or "(no logs yet)" + width, height = 720, 380 + + scroll = NSScrollView.alloc().initWithFrame_(NSMakeRect(0, 0, width, height)) + scroll.setHasVerticalScroller_(True) + scroll.setBorderType_(NSBezelBorder) + + content = scroll.contentSize() + text_view = NSTextView.alloc().initWithFrame_( + NSMakeRect(0, 0, content.width, content.height) + ) + text_view.setMinSize_(NSMakeSize(0, content.height)) + text_view.setMaxSize_(NSMakeSize(1e7, 1e7)) + text_view.setVerticallyResizable_(True) + text_view.setAutoresizingMask_(NSViewWidthSizable) + text_view.setEditable_(False) + text_view.setFont_(NSFont.userFixedPitchFontOfSize_(11)) + text_view.textContainer().setContainerSize_(NSMakeSize(content.width, 1e7)) + text_view.textContainer().setWidthTracksTextView_(True) + text_view.setString_(text) + text_view.scrollRangeToVisible_((len(text), 0)) + scroll.setDocumentView_(text_view) + + alert = NSAlert.alloc().init() + alert.setMessageText_(title) + alert.setInformativeText_(f"Last {len(lines)} log line(s)") + alert.addButtonWithTitle_("Close") + alert.setAccessoryView_(scroll) + alert.runModal() + + +def _choose_directory(default_path: str) -> Optional[str]: + """Open a native folder picker; return the chosen absolute path or None.""" + # Lazy import: macOS-only (AppKit, not available cross-platform) + from AppKit import NSURL, NSOpenPanel + + panel = NSOpenPanel.openPanel() + panel.setCanChooseFiles_(False) + panel.setCanChooseDirectories_(True) + panel.setCanCreateDirectories_(True) + panel.setPrompt_("Select Vault") + parent = Path(default_path).expanduser().parent + if parent.exists(): + panel.setDirectoryURL_(NSURL.fileURLWithPath_(str(parent))) + if panel.runModal() == 1: # NSModalResponseOK + return panel.URLs()[0].path() + return None + + +# --- shared state ------------------------------------------------------------ + + +@dataclass +class SharedState: + """Thread-safe state shared between the rumps UI and the worker thread.""" + + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + status: str = "idle" # idle | starting | pairing | syncing | error + error: Optional[str] = None + connected: bool = False + completion: Optional[float] = None + folder_error: Optional[str] = None # folder-level error from Syncthing + folder_id: Optional[str] = None + vault_dir: str = "" + + def snapshot(self) -> dict: + with self._lock: + return { + "status": self.status, + "error": self.error, + "connected": self.connected, + "completion": self.completion, + "folder_error": self.folder_error, + "folder_id": self.folder_id, + "vault_dir": self.vault_dir, + } + + def update(self, **kwargs) -> None: + with self._lock: + for k, v in kwargs.items(): + setattr(self, k, v) + + +# --- sync manager ------------------------------------------------------------ + + +class VaultSyncManager: + """Coordinates the local Syncthing and the backend pairing handshake.""" + + def __init__(self, state: SharedState) -> None: + self.state = state + self.config = VaultSyncConfig.from_env() + self.syncthing = SyncthingManager() + self.state.update(vault_dir=self.config.local_vault_dir) + self._lock = threading.Lock() + self._logged_errors: set[str] = set() + + def pair_async(self) -> None: + """Run the (blocking) pair flow on a background thread.""" + threading.Thread(target=self._pair, daemon=True).start() + + def _pair(self) -> None: + if not self._lock.acquire(blocking=False): + logger.info("Pair already in progress") + return + try: + cfg = self.config + if not cfg.auth_username or not cfg.auth_password: + self.state.update( + status="error", + error="Set AUTH_USERNAME/AUTH_PASSWORD in .env", + ) + return + + self.state.update(status="starting", error=None) + self.syncthing.start() + local_id = self.syncthing.device_id() + + self.state.update(status="pairing") + token = get_jwt_token(cfg.auth_username, cfg.auth_password, cfg.backend_url) + if not token: + self.state.update(status="error", error="Backend auth failed") + return + + info = broker_pair(cfg.backend_url, token, local_id, cfg.device_name) + + sync_address = info.get("sync_address") or "" + addresses = [sync_address] if sync_address else ["dynamic"] + self.syncthing.ensure_server_device( + info["server_device_id"], "Chronicle Server", addresses + ) + self.syncthing.ensure_folder( + folder_id=info["folder_id"], + path=cfg.local_vault_dir, + label=info.get("folder_label", "Chronicle Vault"), + server_device_id=info["server_device_id"], + self_device_id=local_id, + ) + self.state.update(status="syncing", folder_id=info["folder_id"], error=None) + logger.info( + "Paired. Folder %s -> %s", info["folder_id"], cfg.local_vault_dir + ) + except httpx.HTTPStatusError as e: + detail = e.response.text[:200] if e.response is not None else str(e) + self.state.update(status="error", error=f"Pair failed: {detail}") + logger.error("Pair failed: %s", detail) + except Exception as e: # noqa: BLE001 - surface any failure in the UI + self.state.update(status="error", error=str(e)) + logger.exception("Pair error") + finally: + self._lock.release() + + def set_vault_dir(self, path: str) -> None: + save_vault_dir(path) + self.config.local_vault_dir = path + self.state.update(vault_dir=path) + logger.info("Vault folder set to %s — re-pairing", path) + self.pair_async() + + def refresh_status(self) -> None: + if not self.syncthing.is_running(): + return + connected = self.syncthing.connection_count() > 0 + completion = None + folder_error = None + snap = self.state.snapshot() + if snap["folder_id"]: + fstatus = self.syncthing.folder_status(snap["folder_id"]) + completion = fstatus["completion"] + folder_error = fstatus["error"] + + # Pull Syncthing's own detailed errors into the log buffer so "View Logs" + # shows the real cause (e.g. a case collision), not just a count. Log each + # message once; re-log if it clears and recurs. + detailed = self.syncthing.collect_errors() + for msg in detailed: + if msg not in self._logged_errors: + logger.error("Syncthing: %s", msg) + self._logged_errors = set(detailed) + if detailed: + folder_error = ( + detailed[0] + if len(detailed) == 1 + else f"{detailed[0]} (+{len(detailed) - 1} more)" + ) + + self.state.update( + connected=connected, completion=completion, folder_error=folder_error + ) + + def shutdown(self) -> None: + self.syncthing.stop() + + +# --- rumps menu bar app ------------------------------------------------------- + + +class VaultSyncApp(rumps.App): + def __init__(self, state: SharedState, manager: VaultSyncManager) -> None: + super().__init__("Chronicle Vault", title="◈…") # ◈ + self.state = state + self.manager = manager + + self.status_item = rumps.MenuItem("Status: Starting…", callback=None) + self.conn_item = rumps.MenuItem("Server: …", callback=None) + self.folder_item = rumps.MenuItem("Vault: …", callback=None) + self.menu = [ + self.status_item, + self.conn_item, + self.folder_item, + None, + rumps.MenuItem("Open in Obsidian", callback=self.on_open_obsidian), + rumps.MenuItem("Choose Vault Folder…", callback=self.on_choose_folder), + rumps.MenuItem("Sync Now / Re-pair", callback=self.on_repair), + None, + rumps.MenuItem("Open Syncthing UI", callback=self.on_open_syncthing), + rumps.MenuItem("View Logs", callback=self.on_view_logs), + ] + + @rumps.timer(2) + def refresh_ui(self, _sender) -> None: + self.manager.refresh_status() + snap = self.state.snapshot() + status = snap["status"] + self.folder_item.title = f"Vault: {snap['vault_dir']}" + + if status == "error": + self.title = "◈!" # ◈! + self.status_item.title = f"Status: Error — {snap['error'] or 'unknown'}" + self.conn_item.title = "Server: —" + return + + if status == "syncing": + connected = snap["connected"] + self.conn_item.title = ( + "Server: connected" if connected else "Server: connecting…" + ) + folder_error = snap["folder_error"] + comp = snap["completion"] + if folder_error: + self.title = "◈!" # ◈! + # Truncate for the menu; the full error is in the log buffer (View Logs). + short = ( + folder_error if len(folder_error) <= 80 else folder_error[:79] + "…" + ) + self.status_item.title = f"Status: Folder error — {short}" + elif comp is not None and comp >= 99.9 and connected: + self.title = "◈✓" # ◈✓ + self.status_item.title = "Status: In sync" + else: + self.title = "◈↻" # ◈↻ + pct = f"{comp:.0f}%" if comp is not None else "…" + self.status_item.title = f"Status: Syncing {pct}" + elif status in ("starting", "pairing"): + self.title = "◈…" # ◈… + self.status_item.title = f"Status: {status.capitalize()}…" + self.conn_item.title = "Server: —" + else: + self.title = "◈" + self.status_item.title = "Status: Idle" + + def on_open_obsidian(self, _sender) -> None: + vault_dir = self.state.snapshot()["vault_dir"] + uri = f"obsidian://open?path={quote(vault_dir)}" + try: + subprocess.run(["open", uri], check=False) + logger.info("Opened %s in Obsidian", vault_dir) + except Exception as e: # noqa: BLE001 + logger.error("Failed to open Obsidian: %s", e) + subprocess.run(["open", vault_dir], check=False) # reveal in Finder + + def on_choose_folder(self, _sender) -> None: + current = self.state.snapshot()["vault_dir"] + chosen = _choose_directory(current) + if chosen: + self.manager.set_vault_dir(chosen) + + def on_repair(self, _sender) -> None: + logger.info("User requested re-pair") + self.manager.pair_async() + + def on_open_syncthing(self, _sender) -> None: + url = self.manager.syncthing.base_url + logger.info("Opening Syncthing UI at %s", url) + subprocess.run(["open", url], check=False) + + def on_view_logs(self, _sender) -> None: + _show_logs_dialog("Chronicle Vault Sync — Logs", list(log_buffer.lines)) + + +# --- entry point -------------------------------------------------------------- + + +def main() -> None: + # Lazy import: macOS-only (AppKit, not available cross-platform) + from AppKit import NSApplication + + NSApplication.sharedApplication().setActivationPolicy_(1) # menu bar only + + log_format = "%(asctime)s %(levelname)s %(name)s: %(message)s" + logging.basicConfig(format=log_format, level=logging.INFO) + log_buffer.setFormatter(logging.Formatter(log_format)) + logging.getLogger().addHandler(log_buffer) + logging.getLogger("httpx").setLevel(logging.WARNING) + + state = SharedState() + manager = VaultSyncManager(state) + manager.pair_async() # auto-start sync on launch + + app = VaultSyncApp(state, manager) + try: + app.run() + finally: + manager.shutdown() + + +if __name__ == "__main__": + main() diff --git a/extras/vault-sync/pyproject.toml b/extras/vault-sync/pyproject.toml new file mode 100644 index 00000000..2b1e4169 --- /dev/null +++ b/extras/vault-sync/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "chronicle-vault-sync" +version = "0.1.0" +description = "macOS menu bar app that syncs your Chronicle Obsidian vault via Syncthing." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.27.0", + "python-dotenv>=1.0.0", + "rumps>=0.4.0; sys_platform == 'darwin'", + "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'", + "minidisc-python>=0.1.0", +] diff --git a/extras/vault-sync/service.py b/extras/vault-sync/service.py new file mode 100644 index 00000000..2d37eb5a --- /dev/null +++ b/extras/vault-sync/service.py @@ -0,0 +1,213 @@ +"""launchd service management for Chronicle Vault Sync on macOS.""" + +import os +import plistlib +import shutil +import subprocess +import sys +from pathlib import Path + +from dotenv import dotenv_values + +LABEL = "com.chronicle.vault-sync" +PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist" +LOG_DIR = Path.home() / "Library" / "Logs" / "Chronicle" +LOG_FILE = LOG_DIR / "vault-sync.log" +APP_BUNDLE = Path.home() / "Applications" / "Chronicle Vault Sync.app" + +PROJECT_DIR = Path(__file__).resolve().parent + + +def _find_uv() -> str: + uv = shutil.which("uv") + if uv: + return uv + for candidate in ( + Path.home() / ".local" / "bin" / "uv", + Path.home() / ".cargo" / "bin" / "uv", + Path("/usr/local/bin/uv"), + Path("/opt/homebrew/bin/uv"), + ): + if candidate.exists(): + return str(candidate) + print( + "Error: could not find 'uv'. Install it: curl -LsSf https://astral.sh/uv/install.sh | sh" + ) + sys.exit(1) + + +def _create_app_bundle() -> None: + """Create a .app via osacompile so Spotlight/Raycast treat it as a real app.""" + if APP_BUNDLE.exists(): + shutil.rmtree(APP_BUNDLE) + + applescript = ( + f'do shell script "launchctl kickstart gui/" & ' + f'(do shell script "id -u") & "/{LABEL}"' + ) + result = subprocess.run( + ["osacompile", "-o", str(APP_BUNDLE), "-e", applescript], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"osacompile failed: {result.stderr.strip()}") + return + + info_plist = APP_BUNDLE / "Contents" / "Info.plist" + with open(info_plist, "rb") as f: + info = plistlib.load(f) + info.update( + { + "CFBundleName": "Chronicle Vault Sync", + "CFBundleDisplayName": "Chronicle Vault Sync", + "CFBundleIdentifier": LABEL, + "CFBundleVersion": "1.0", + "CFBundleShortVersionString": "1.0", + "LSUIElement": True, + } + ) + with open(info_plist, "wb") as f: + plistlib.dump(info, f) + + +def _remove_app_bundle() -> None: + if APP_BUNDLE.exists(): + shutil.rmtree(APP_BUNDLE) + print(f"Removed {APP_BUNDLE}") + + +def _build_plist() -> dict: + uv = _find_uv() + + env = {} + env_file = PROJECT_DIR / ".env" + if env_file.exists(): + for key, value in dotenv_values(env_file).items(): + if value is not None: + env[key] = value + # Ensure Homebrew bin is on PATH so the syncthing binary is found under launchd. + env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + os.environ.get("PATH", "") + + return { + "Label": LABEL, + "ProgramArguments": [ + uv, + "run", + "--project", + str(PROJECT_DIR), + "python", + str(PROJECT_DIR / "main.py"), + "menu", + ], + "WorkingDirectory": str(PROJECT_DIR), + "RunAtLoad": True, + "KeepAlive": {"SuccessfulExit": False}, + "ThrottleInterval": 10, + "ProcessType": "Interactive", + "StandardOutPath": str(LOG_FILE), + "StandardErrorPath": str(LOG_FILE), + "EnvironmentVariables": env, + } + + +def install() -> None: + LOG_DIR.mkdir(parents=True, exist_ok=True) + PLIST_PATH.parent.mkdir(parents=True, exist_ok=True) + + plist = _build_plist() + + if PLIST_PATH.exists(): + print(f"Removing existing agent: {LABEL}") + subprocess.run( + ["launchctl", "bootout", f"gui/{os.getuid()}", str(PLIST_PATH)], + capture_output=True, + ) + + with open(PLIST_PATH, "wb") as f: + plistlib.dump(plist, f) + print(f"Wrote plist to {PLIST_PATH}") + + result = subprocess.run( + ["launchctl", "bootstrap", f"gui/{os.getuid()}", str(PLIST_PATH)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f"Service '{LABEL}' installed and loaded.") + print(f"Logs: {LOG_FILE}") + else: + print(f"launchctl bootstrap failed: {result.stderr.strip()}") + print("Try: launchctl bootstrap gui/$(id -u) " + str(PLIST_PATH)) + + _create_app_bundle() + print(f"Created launcher app: {APP_BUNDLE}") + + +def uninstall() -> None: + if not PLIST_PATH.exists(): + print(f"No plist found at {PLIST_PATH}") + return + + result = subprocess.run( + ["launchctl", "bootout", f"gui/{os.getuid()}", str(PLIST_PATH)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f"Service '{LABEL}' unloaded.") + else: + print(f"launchctl bootout: {result.stderr.strip()}") + + PLIST_PATH.unlink(missing_ok=True) + print(f"Removed {PLIST_PATH}") + _remove_app_bundle() + + +def kickstart() -> None: + if not PLIST_PATH.exists(): + print("Service not installed. Run './start.sh install' first.") + return + result = subprocess.run( + ["launchctl", "kickstart", "-k", f"gui/{os.getuid()}/{LABEL}"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f"Service '{LABEL}' restarted.") + else: + print(f"Failed to restart: {result.stderr.strip()}") + + +def status() -> None: + if not PLIST_PATH.exists(): + print(f"Service not installed (no plist at {PLIST_PATH})") + return + result = subprocess.run( + ["launchctl", "print", f"gui/{os.getuid()}/{LABEL}"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + stripped = line.strip() + if any( + k in stripped.lower() for k in ["state", "pid", "last exit", "runs"] + ): + print(stripped) + else: + print(f"Service '{LABEL}' is not running.") + + +def logs(follow: bool = True) -> None: + if not LOG_FILE.exists(): + print(f"No log file at {LOG_FILE}") + return + if follow: + print(f"Tailing {LOG_FILE} (Ctrl+C to stop)...") + try: + subprocess.run(["tail", "-f", str(LOG_FILE)]) + except KeyboardInterrupt: + pass + else: + print(LOG_FILE.read_text()[-5000:]) diff --git a/extras/vault-sync/start.sh b/extras/vault-sync/start.sh new file mode 100755 index 00000000..36969692 --- /dev/null +++ b/extras/vault-sync/start.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Launch the Chronicle Vault Sync menu bar app (or pass a subcommand: install, logs, ...). +set -a && source .env 2>/dev/null; set +a +exec uv run python main.py "$@" diff --git a/extras/vault-sync/syncthing_manager.py b/extras/vault-sync/syncthing_manager.py new file mode 100644 index 00000000..44bf0174 --- /dev/null +++ b/extras/vault-sync/syncthing_manager.py @@ -0,0 +1,300 @@ +"""Manage a local headless Syncthing instance on the Mac and drive its REST API. + +The menu bar app owns a private Syncthing process (separate home dir + GUI port from +any Syncthing the user may already run), and configures it entirely over REST so the +user never touches the Syncthing UI. Pairing with the server is brokered by the +Chronicle backend; this module only handles the local side. +""" + +import logging +import os +import secrets +import shutil +import subprocess +import time +from pathlib import Path +from typing import List, Optional + +import httpx + +logger = logging.getLogger(__name__) + +APP_SUPPORT = ( + Path.home() / "Library" / "Application Support" / "Chronicle" / "vault-sync" +) +SYNCTHING_HOME = APP_SUPPORT / "syncthing" # config, keys, index db +APIKEY_FILE = APP_SUPPORT / "apikey" +# Default off 8385 so we don't collide with a user's own Syncthing on 8384. +GUI_PORT = int(os.getenv("VAULT_SYNC_GUI_PORT", "8385")) + +# Obsidian's per-vault workspace/config dir is local state, not content worth +# syncing. Ignore it and everything under it on every paired device. +VAULT_IGNORE_PATTERNS = [".obsidian", ".obsidian/**"] + + +def _find_binary() -> str: + """Locate the syncthing binary, preferring PATH then common Homebrew locations.""" + exe = shutil.which("syncthing") + if exe: + return exe + for candidate in ( + Path("/opt/homebrew/bin/syncthing"), + Path("/usr/local/bin/syncthing"), + ): + if candidate.exists(): + return str(candidate) + raise FileNotFoundError( + "syncthing not found. Install it with: brew install syncthing" + ) + + +def _api_key() -> str: + """Return a persistent local API key, generating one on first run.""" + APP_SUPPORT.mkdir(parents=True, exist_ok=True) + if APIKEY_FILE.exists(): + return APIKEY_FILE.read_text().strip() + key = secrets.token_hex(24) + APIKEY_FILE.write_text(key) + APIKEY_FILE.chmod(0o600) + return key + + +class SyncthingManager: + """Owns the local Syncthing subprocess and its REST configuration.""" + + def __init__(self) -> None: + self.binary = _find_binary() + self.api_key = _api_key() + self.base_url = f"http://127.0.0.1:{GUI_PORT}" + self._proc: Optional[subprocess.Popen] = None + self._log = None + + # --- process lifecycle --------------------------------------------------- + + def start(self) -> None: + if self.is_running(): + return + SYNCTHING_HOME.mkdir(parents=True, exist_ok=True) + env = dict(os.environ, STGUIAPIKEY=self.api_key) + # Capture output so a failed launch (e.g. bad flag) surfaces in the error + # instead of hiding behind a generic "did not become ready" timeout. + self._log = open(APP_SUPPORT / "syncthing.log", "ab") + self._proc = subprocess.Popen( + [ + self.binary, + "serve", + "--home", + str(SYNCTHING_HOME), + "--gui-address", + f"127.0.0.1:{GUI_PORT}", + "--gui-apikey", + self.api_key, + "--no-browser", + ], + env=env, + stdout=self._log, + stderr=subprocess.STDOUT, + ) + self._wait_ready() + logger.info("Local Syncthing ready on %s", self.base_url) + + def _wait_ready(self, timeout: float = 30.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if self._proc and self._proc.poll() is not None: + raise RuntimeError( + f"Syncthing exited early (code {self._proc.returncode}). " + f"See {APP_SUPPORT / 'syncthing.log'}" + ) + try: + with self._client() as c: + if c.get("/rest/system/ping").status_code == 200: + return + except httpx.HTTPError: + pass + time.sleep(0.5) + raise RuntimeError("Local Syncthing did not become ready in time") + + def stop(self) -> None: + if self._proc and self._proc.poll() is None: + self._proc.terminate() + try: + self._proc.wait(timeout=10) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc = None + + def is_running(self) -> bool: + return self._proc is not None and self._proc.poll() is None + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self.base_url, + headers={"X-API-Key": self.api_key}, + timeout=10.0, + ) + + # --- REST operations ----------------------------------------------------- + + def device_id(self) -> str: + with self._client() as c: + resp = c.get("/rest/system/status") + resp.raise_for_status() + return resp.json()["myID"] + + def ensure_server_device( + self, device_id: str, name: str, addresses: List[str] + ) -> None: + """Upsert the Chronicle server as a Syncthing device to dial.""" + with self._client() as c: + existing = c.get(f"/rest/config/devices/{device_id}") + if existing.status_code == 200: + device = existing.json() + else: + template = c.get("/rest/config/defaults/device") + template.raise_for_status() + device = template.json() + device["deviceID"] = device_id + device["name"] = name + device["addresses"] = addresses or ["dynamic"] + resp = c.put(f"/rest/config/devices/{device_id}", json=device) + resp.raise_for_status() + + def ensure_folder( + self, + folder_id: str, + path: str, + label: str, + server_device_id: str, + self_device_id: str, + ) -> None: + """Upsert the vault folder mapped to the local path, shared with the server. + + Any *other* folder already bound to the same local path is detached first. + Syncthing refuses to run two folders that share a directory, so a leftover + folder from a previous pairing would silently wedge sync. This happens when + the backend re-creates the admin user with a new id (e.g. after a data + reset): ``folder_id`` is ``vault-{user_id}``, so a new user id means a new + folder pointed at the same local vault dir. Detaching the stale one lets the + current vault take over the path cleanly. + """ + Path(path).mkdir(parents=True, exist_ok=True) + target = os.path.realpath(path) + with self._client() as c: + self._detach_folders_at_path(c, target, keep_id=folder_id) + existing = c.get(f"/rest/config/folders/{folder_id}") + if existing.status_code == 200: + folder = existing.json() + else: + template = c.get("/rest/config/defaults/folder") + template.raise_for_status() + folder = template.json() + folder["id"] = folder_id + folder["type"] = "sendreceive" + folder["label"] = label + folder["path"] = path + shared = {d.get("deviceID") for d in folder.get("devices", [])} + for dev in (self_device_id, server_device_id): + if dev not in shared: + folder.setdefault("devices", []).append({"deviceID": dev}) + resp = c.put(f"/rest/config/folders/{folder_id}", json=folder) + resp.raise_for_status() + self._set_ignores(c, folder_id, VAULT_IGNORE_PATTERNS) + + @staticmethod + def _detach_folders_at_path(c: httpx.Client, target: str, keep_id: str) -> None: + """Remove any folder bound to ``target`` whose id differs from ``keep_id``.""" + resp = c.get("/rest/config/folders") + resp.raise_for_status() + for folder in resp.json(): + fid = folder.get("id") + if not fid or fid == keep_id: + continue + if os.path.realpath(folder.get("path", "")) == target: + logger.info("Detaching stale folder %s bound to %s", fid, target) + c.delete(f"/rest/config/folders/{fid}").raise_for_status() + + @staticmethod + def _set_ignores(c: httpx.Client, folder_id: str, patterns: list[str]) -> None: + """Write the folder's .stignore patterns via REST.""" + resp = c.post( + "/rest/db/ignores", + params={"folder": folder_id}, + json={"ignore": patterns}, + ) + resp.raise_for_status() + + def connection_count(self) -> int: + """Number of currently connected remote devices (0 or 1 in normal use).""" + try: + with self._client() as c: + resp = c.get("/rest/system/connections") + if resp.status_code != 200: + return 0 + conns = resp.json().get("connections", {}) + return sum(1 for v in conns.values() if v.get("connected")) + except httpx.HTTPError: + return 0 + + def folder_status(self, folder_id: str) -> dict: + """Return ``{"completion", "state", "error"}`` for the folder. + + ``completion`` is the percent of bytes in sync (None if unknown). An empty + folder only reads as 100% when Syncthing also reports it idle with no error: + a wedged folder (overlapping path, missing marker, scan failure) surfaces its + error instead of a misleading 100%, which previously made a broken sync show + as "In sync". + """ + unknown = {"completion": None, "state": None, "error": None} + try: + with self._client() as c: + resp = c.get("/rest/db/status", params={"folder": folder_id}) + if resp.status_code != 200: + return unknown + data = resp.json() + state = data.get("state") + error = data.get("error") or None + if not error and data.get("errors"): + error = f"{data['errors']} item error(s)" + total = data.get("globalBytes", 0) + need = data.get("needBytes", 0) + if total <= 0: + completion = ( + 100.0 if state in ("idle", None) and not error else None + ) + else: + completion = max(0.0, min(100.0, (1 - need / total) * 100.0)) + return {"completion": completion, "state": state, "error": error} + except httpx.HTTPError: + return unknown + + def collect_errors(self) -> List[str]: + """Return detailed Syncthing-side errors (system + per-folder pull failures). + + ``folder_status`` only reports a count ("N item error(s)"); the actual + messages — case collisions, permission denials, failed pulls — live in + ``/rest/folder/errors`` and ``/rest/system/error``. The menu polls this so + the real cause reaches "View Logs" instead of staying buried in Syncthing. + """ + messages: List[str] = [] + try: + with self._client() as c: + sys_resp = c.get("/rest/system/error") + if sys_resp.status_code == 200: + for e in sys_resp.json().get("errors") or []: + messages.append(f"system: {e.get('message', e)}") + + folders = c.get("/rest/config/folders") + if folders.status_code != 200: + return messages + for f in folders.json(): + fid = f["id"] + label = f.get("label") or fid + resp = c.get("/rest/folder/errors", params={"folder": fid}) + if resp.status_code != 200: + continue + for e in resp.json().get("errors") or []: + messages.append(f"{label}: {e.get('path')}: {e.get('error')}") + except httpx.HTTPError as e: + logger.debug("Could not fetch Syncthing errors: %s", e) + return messages diff --git a/extras/vault-sync/vault_core.py b/extras/vault-sync/vault_core.py new file mode 100644 index 00000000..24f2e986 --- /dev/null +++ b/extras/vault-sync/vault_core.py @@ -0,0 +1,105 @@ +"""Config, backend auth, and pairing-broker calls for the vault-sync app. + +The Mac authenticates to Chronicle with its normal JWT and asks the backend's +``/api/vault-sync`` broker to pair it. The broker returns the server's Syncthing +device id + sync address + this user's folder id, which the local Syncthing is then +configured with (see syncthing_manager). +""" + +import logging +import os +import socket +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +# Make the repo-root discovery.py importable when running from the repo checkout. +_REPO_ROOT = Path(__file__).resolve().parents[2] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +# Persisted local vault directory (set via the "Choose Vault Folder…" menu item). +APP_SUPPORT = ( + Path.home() / "Library" / "Application Support" / "Chronicle" / "vault-sync" +) +VAULT_DIR_FILE = APP_SUPPORT / "vault_dir.txt" + + +def _persisted_vault_dir() -> Optional[str]: + try: + if VAULT_DIR_FILE.exists(): + return VAULT_DIR_FILE.read_text().strip() or None + except OSError: + pass + return None + + +def save_vault_dir(path: str) -> None: + APP_SUPPORT.mkdir(parents=True, exist_ok=True) + VAULT_DIR_FILE.write_text(path) + + +@dataclass +class VaultSyncConfig: + backend_url: str + auth_username: str + auth_password: str + local_vault_dir: str + device_name: str + + @classmethod + def from_env(cls) -> "VaultSyncConfig": + # Lazy import: sys.path-dependent (repo-root discovery.py, inserted above) + from discovery import resolve_backend_url + + backend_url = resolve_backend_url(os.getenv("BACKEND_URL"), logger=logger) + + vault_dir = ( + _persisted_vault_dir() or os.getenv("LOCAL_VAULT_DIR") or "~/ChronicleVault" + ) + + return cls( + backend_url=backend_url, + auth_username=os.getenv("AUTH_USERNAME") or os.getenv("ADMIN_EMAIL", ""), + auth_password=os.getenv("AUTH_PASSWORD") or os.getenv("ADMIN_PASSWORD", ""), + local_vault_dir=os.path.expanduser(vault_dir), + device_name=os.getenv("DEVICE_NAME") or socket.gethostname(), + ) + + +def get_jwt_token(username: str, password: str, backend_url: str) -> Optional[str]: + """Exchange email+password for a JWT, or return None on failure.""" + try: + with httpx.Client(timeout=10.0) as client: + resp = client.post( + f"{backend_url}/auth/jwt/login", + data={"username": username, "password": password}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code == 200: + return resp.json().get("access_token") + logger.error("Backend auth failed: HTTP %s", resp.status_code) + except httpx.HTTPError as e: + logger.error("Backend auth error: %s", e) + return None + + +def broker_pair(backend_url: str, token: str, device_id: str, device_name: str) -> dict: + """Ask the backend to register this device and share the user's vault folder. + + Returns the broker payload: server_device_id, sync_address, folder_id, folder_label. + Raises httpx.HTTPStatusError on a non-2xx response. + """ + with httpx.Client(timeout=15.0) as client: + resp = client.post( + f"{backend_url}/api/vault-sync/pair", + headers={"Authorization": f"Bearer {token}"}, + json={"device_id": device_id, "device_name": device_name}, + ) + resp.raise_for_status() + return resp.json() diff --git a/extras/wakeword-service/.dockerignore b/extras/wakeword-service/.dockerignore new file mode 100644 index 00000000..43342195 --- /dev/null +++ b/extras/wakeword-service/.dockerignore @@ -0,0 +1,15 @@ +* +!app.py +!consumer.py +!detector.py +!hubert_detector.py +!samples.py +!verifier.py +!pyproject.toml +!README.md +!models/ +!models/** +!vendor/ +!vendor/** +!tones/ +!tones/** diff --git a/extras/wakeword-service/.env.template b/extras/wakeword-service/.env.template new file mode 100644 index 00000000..97304de9 --- /dev/null +++ b/extras/wakeword-service/.env.template @@ -0,0 +1,32 @@ +# Hermes Acoustic Wake-Word Service configuration +# Copy to .env (or run init.py) and adjust as needed. + +# Shared backend Redis (same chronicle-network). The standalone detector reads +# audio:stream:* and publishes wakeword:detections here. +REDIS_URL=redis://redis:6379/0 + +# Health/status HTTP port (host side). +WAKEWORD_PORT=8770 + +# Acoustic detection threshold. Favor precision — a false agent call is worse +# than a missed wake word. Raise toward 0.95+ if you see false activations. +WAKEWORD_THRESHOLD=0.9 + +# Require this many CONSECUTIVE frames over threshold before firing — the main +# false-positive suppressor. 2 is the tuned point (held-out: 5.4 FP/hr, 95.6% +# recall for "hey hermes"). Raise to cut FP further (costs recall). +WAKEWORD_PATIENCE=2 + +# Suppress repeat arming within this many seconds. +WAKEWORD_DEBOUNCE_SECS=3.0 + +# Silero VAD speech-probability threshold used during command capture. +WAKEWORD_VAD_THRESHOLD=0.5 + +# Silence (seconds) that forces end-of-turn if Smart Turn hasn't fired. +WAKEWORD_STOP_SECS=2.0 + +# Hard cap on command-capture duration after arming. +WAKEWORD_MAX_ARM_SECS=15.0 + +LOG_LEVEL=INFO diff --git a/extras/wakeword-service/.gitignore b/extras/wakeword-service/.gitignore new file mode 100644 index 00000000..f7463a0f --- /dev/null +++ b/extras/wakeword-service/.gitignore @@ -0,0 +1,28 @@ +# Trained model artifacts (large binaries; produced by training/, vendored at deploy) +models/*.onnx +models/*.pt + +# Locally-built nanowakeword wheel (staged by build_nanowakeword.sh) +vendor/ + +# Captured data-collection clips (false-positive / primed-positive WAVs + meta). +# User audio + regenerable training inputs — never commit. +data/* +# ...but keep the reviewed-clip registry dir (personal audio/index stay ignored; +# only the placeholder is tracked so a fresh clone has the dir). +!data/reviewed_registry/ +data/reviewed_registry/* +!data/reviewed_registry/.gitkeep + +# Training scratch — datasets, generated audio/features, venvs, logs. +# Everything here is downloadable (fetch_datasets.sh) or regenerable (configs). +training/.venv-train/ +training/.venv/ +training/data/ +training/data_smoke/ +training/trained_models/ +training/NwwResourcesModel/ +training/SonicWeave-v2/ +training/*.npy +training/*.zip +training/*.log diff --git a/extras/wakeword-service/Dockerfile b/extras/wakeword-service/Dockerfile new file mode 100644 index 00000000..9c6c5589 --- /dev/null +++ b/extras/wakeword-service/Dockerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1 + +######################### builder ################################# +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Dependency manifest first for cache-friendly installs. +COPY pyproject.toml ./ +# nanowakeword (interpreter core) is installed from a locally-built wheel staged +# by build_nanowakeword.sh — PyPI lacks 2.1.4 (the version the model was exported +# with). Run ./build_nanowakeword.sh before `docker compose build`. +COPY vendor/ ./vendor/ +RUN uv pip install --system --no-cache \ + "fastapi>=0.115,<1" \ + "uvicorn[standard]>=0.30,<1" \ + "redis>=5.0,<6" \ + "numpy>=1.26,<3" \ + "onnxruntime>=1.20,<2" \ + "pipecat-ai==0.0.108" \ + "soxr>=0.5,<2" \ + "setuptools==69.5.1" \ + ./vendor/nanowakeword-*.whl + +######################### runtime ################################# +FROM python:3.12-slim-bookworm AS runtime +ENV PYTHONUNBUFFERED=1 +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libsndfile1 curl \ + && rm -rf /var/lib/apt/lists/* + +# Bring in the installed site-packages and console scripts from the builder. +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Service code + vendored ONNX models (hermes.onnx, smart-turn, silero, feature). +# models/ also carries the second-stage verifier weights (hey_hermes_verifier.npz). +COPY app.py consumer.py detector.py samples.py verifier.py hubert_detector.py ./ +COPY models ./models +COPY tones ./tones + +# Pre-place the nanowakeword feature ONNX (melspec + embedding) into the package +# location the NanoInterpreter expects, so no network fetch happens at runtime. +RUN NW_MODELS="/usr/local/lib/python3.12/site-packages/nanowakeword/interpreter/models" && \ + mkdir -p "$NW_MODELS/mel_spectrogram" "$NW_MODELS/embedding" && \ + if [ -f /app/models/melspectrogram.onnx ]; then \ + cp /app/models/melspectrogram.onnx "$NW_MODELS/mel_spectrogram/"; fi && \ + if [ -f /app/models/embedding_model.onnx ]; then \ + cp /app/models/embedding_model.onnx "$NW_MODELS/embedding/"; fi + +# HuBERT backend (.pt wake models, e.g. hermes_hubert_convattn.pt): CUDA torch + +# torchaudio (the cu121 wheels bundle the CUDA runtime libs; the host only needs the +# NVIDIA driver + the nvidia container runtime). The HuBERT-base weights are then +# pre-cached into the image so no model download happens at runtime. Routing in +# detector.py sends ``.pt`` models here and ``.onnx`` to NanoInterpreter, so an +# ONNX-only deploy still works (it just carries torch unused). +RUN pip install --no-cache-dir \ + --index-url https://download.pytorch.org/whl/cu121 \ + "torch==2.5.1" "torchaudio==2.5.1" +RUN python -c "import torchaudio; torchaudio.pipelines.HUBERT_BASE.get_model(); print('HuBERT-base cached')" + +ENV WAKEWORD_MODEL_PATH=/app/models/hey_hermes.onnx \ + SMART_TURN_MODEL_PATH=/app/models/smart-turn-v3.2-cpu.onnx \ + SILERO_VAD_MODEL_PATH=/app/models/silero_vad.onnx \ + WAKEWORD_SERVICE_PORT=8770 + +EXPOSE 8770 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:8770/health || exit 1 + +CMD ["python", "app.py"] diff --git a/extras/wakeword-service/README.md b/extras/wakeword-service/README.md new file mode 100644 index 00000000..d7619fbc --- /dev/null +++ b/extras/wakeword-service/README.md @@ -0,0 +1,132 @@ +# Hermes Acoustic Wake-Word Service + +A standalone Chronicle service that gives Hermes a real **acoustic** wake word. +It consumes the live audio stream, detects "Hermes" acoustically, captures the +command the user speaks afterward using semantic end-of-turn detection, and +forwards it to the existing Hermes agent — running **in parallel** with the +existing text-keyword trigger. + +## How it works + +``` +audio:stream:{client_id} (Redis, 0.25s PCM frames @ 16kHz/16-bit/mono) + │ consumer group: wakeword_detection + ▼ +wakeword-service (this service) + ├─ NanoInterpreter(.onnx).predict() per word → acoustic hit → ARM + ├─ Silero VAD + Smart Turn v3 (pipecat, ONNX) → capture command until end-of-turn + └─ resolve command text from transcription:results:{session_id} (no 2nd ASR) + ▼ + XADD wakeword:detections { client_id, session_id, user_id, command, score, ... } + ▼ + backend dispatcher → PluginRouter.dispatch_event("wake_word.detected", …) + ▼ + Hermes plugin → external Hermes agent /v1/chat/completions +``` + +Both detection paths converge on the **same** Hermes plugin entry point, so the +acoustic and text triggers reach the agent identically. + +### Command-text source + +After the wake word arms, the service does **not** run a second ASR. It records +the arm timestamp, lets Silero VAD + Smart Turn v3 detect the end of the +command turn, then reads the existing `transcription:results:{session_id}` +Redis stream and concatenates the transcript chunks whose timestamps fall in the +armed window. This reuses Chronicle's existing transcription. + +## Models (in `models/`) + +| File | Purpose | Source | +|------|---------|--------| +| `hermes.onnx` | Acoustic wake-word model | trained — see `training/` | +| `smart-turn-v3.2-cpu.onnx` | Semantic end-of-turn | pipecat (vendored) | +| `silero_vad.onnx` | Voice activity detection | pipecat (vendored) | +| `melspectrogram.onnx`, `embedding_model.onnx` | nanowakeword features | nanowakeword (vendored) | + +Vendor/refresh the non-trained ONNX files with `./vendor_models.sh`. + +## Training the wake word + +The acoustic model is trained with `untracked/nanowakeword` on a GPU. See +`training/` for the config and helpers. Output goes to +`extras/wakeword-service/models/hermes.onnx`. + +```bash +cd training +# one-time: create venv and install deps (uses the repo's vendored nanowakeword) +uv venv --python 3.12 .venv-train +VIRTUAL_ENV=$(pwd)/.venv-train uv pip install \ + -e "../../../untracked/nanowakeword[train]" setuptools==69.5.1 piper-tts onnxruntime-gpu + +# fetch background-noise + negative datasets (multi-GB, see hermes_config.yaml comments) +# then run the full pipeline (generate clips → features → train → ONNX export) +.venv-train/bin/nanowakeword -c hermes_config.yaml +cp trained_models/hermes/model/hermes.onnx ../models/hermes.onnx +``` + +## Running + +```bash +# Requires the backend stack (Redis on chronicle-network) to be up. +docker compose up --build -d + +# Health / status +curl http://localhost:8770/health +curl http://localhost:8770/status +``` + +## Multiple wake words (run in parallel) + +The service runs **N wake words at once** — e.g. the low-FP phrase `hey_hermes` +plus the single word `hermes`. Configure them with `WAKEWORD_MODELS` as ordered +`name:file` pairs (file under `models/`); list order is arming priority when an +utterance trips several models (the lower-FP word first): + +``` +WAKEWORD_MODELS=hey_hermes:hey_hermes_c.onnx,hermes:hermes.onnx +``` + +The wake-word **name** (`hey_hermes`) is decoupled from the model **file** +(`hey_hermes_c.onnx`), so a retrained model is swapped without renaming its +sample-data dir or plugin condition. Each word may have a per-deployment +second-stage verifier at `models/_verifier.npz` (auto-enabled when present) +and per-word threshold/patience overrides. Each frame is scored against every +word; a single arm fires per debounce window (co-firing words are tagged +`also_fired`, never double-dispatched). + +Because `hermes` is acoustically a substring of `hey hermes`, clean per-word +**positive** data comes from the per-word "I'll say it now" enrollment in the +Wake-Word Lab (the user declares which word). Captured clips are stored under +`data/samples//{pending,positive,negative}/` and never cross-written. + +## Configuration (env) + +| Var | Default | Meaning | +|-----|---------|---------| +| `REDIS_URL` | `redis://redis:6379/0` | Shared backend Redis | +| `WAKEWORD_MODELS` | `hey_hermes:hey_hermes_c.onnx` | Ordered `name:file` wake words (priority order) | +| `WAKEWORD_THRESHOLD` | `0.9` | Default detection threshold (favor precision) | +| `WAKEWORD_PATIENCE` | `2` | Default consecutive-frames-over-threshold to arm | +| `WAKEWORD_THRESHOLDS` | — | Per-word threshold overrides, e.g. `hermes:0.97` | +| `WAKEWORD_PATIENCES` | — | Per-word patience overrides, e.g. `hermes:3` | +| `WAKEWORD_VERIFIERS` | — | Per-word verifier path overrides, `name:path,...` | +| `WAKEWORD_VERIFIER_THRESHOLD` | — | Override every verifier's trained op-point | +| `WAKEWORD_DEBOUNCE_SECS` | `3.0` | Suppress repeat arming window (shared) | +| `WAKEWORD_VAD_THRESHOLD` | `0.5` | Silero speech threshold | +| `WAKEWORD_STOP_SECS` | `2.0` | Silence that forces end-of-turn (backstop) | +| `WAKEWORD_MAX_ARM_SECS` | `15.0` | Hard cap on capture duration | +| `WAKEWORD_LEGACY` | `hey_hermes` | Word that owns pre-multi-word flat sample dirs | +| `WAKEWORD_PORT` | `8771` | Health/status HTTP host port (container 8770) | + +## Backend wiring + +The standalone detector cannot call the in-process plugin router directly, so it +publishes to the `wakeword:detections` Redis stream. The backend runs a small +consumer (`services/wakeword/dispatcher.py`) that reads that stream and calls +`PluginRouter.dispatch_event("wake_word.detected", …)`. The Hermes plugin's +`on_wake_word_detected` handler shares the exact agent-call path as the text +trigger's `on_transcript`. + +The text trigger (`keyword_anywhere: [hermes]` in `config/plugins.yml`) stays +active — both paths run in parallel. diff --git a/extras/wakeword-service/app.py b/extras/wakeword-service/app.py new file mode 100644 index 00000000..0a507065 --- /dev/null +++ b/extras/wakeword-service/app.py @@ -0,0 +1,697 @@ +"""Acoustic wake-word service (multi-wake-word). + +Standalone service that consumes the live ``audio:stream:*`` Redis stream, +detects one or more acoustic wake words (e.g. "hey hermes" + "hermes") in +parallel, captures the following command turn via Silero VAD + Smart Turn v3, +and publishes a ``wake_word.detected`` event (tagged with which word fired) to +the ``wakeword:detections`` Redis stream for the backend to forward to the +Hermes plugin. + +Wake words are configured via ``WAKEWORD_MODELS`` as a comma-separated list of +``name:file`` pairs (file relative to the models dir), e.g.:: + + WAKEWORD_MODELS=hey_hermes:hey_hermes_c.onnx,hermes:hermes.onnx + +The wake-word NAME (``hey_hermes``) is decoupled from the model FILE +(``hey_hermes_c.onnx``) so a model can be swapped/retrained without renaming its +sample-data directory or its plugin condition. List order is arming PRIORITY when +several words fire on one frame (put the lower-FP word first). Each word may have +a per-deployment second-stage verifier at ``models/_verifier.npz`` (auto- +enabled when present) and per-word threshold/patience overrides. + +Runs a small FastAPI app for health/status + the data-collection flywheel API. +""" + +import asyncio +import json +import logging +import os +from contextlib import asynccontextmanager + +import uvicorn +from consumer import DETECTIONS_STREAM, GROUP_NAME, WakeWordConsumer +from detector import HermesDetector +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import Response +from pydantic import BaseModel +from samples import BUCKETS, PENDING, SampleStore + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("wakeword-service") + +# Directory holding the wake-word ONNX models (+ optional verifiers). +MODELS_DIR = os.getenv("WAKEWORD_MODELS_DIR", "/app/models") +# Wake words to run, as "name:file" pairs (file relative to MODELS_DIR). Order is +# arming priority. The lower-FP two-word phrase goes first. +WAKEWORD_MODELS = os.getenv("WAKEWORD_MODELS", "hey_hermes:hey_hermes_c.onnx") +# Pre-multi-wake-word flat sample dirs (data/samples/) are all "hey +# hermes" — relocate them under this word so they don't contaminate a second one. +LEGACY_WAKEWORD = os.getenv("WAKEWORD_LEGACY", "hey_hermes") +# Words in collect-only (shadow) mode fire to farm false-positive review data but +# never dispatch a command / play a tone / block a real wake word. Use this to run +# a not-yet-trusted word (e.g. the FP-prone single "hermes") live for data only. +WAKEWORD_COLLECT_ONLY = os.getenv("WAKEWORD_COLLECT_ONLY", "") + +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") +# Default operating point (per-word overrides via WAKEWORD_THRESHOLDS / _PATIENCES). +THRESHOLD = float(os.getenv("WAKEWORD_THRESHOLD", "0.9")) +# patience=2 (consecutive frames) is the shippable operating point for "hey +# hermes": 0.9/2 -> 5.4 FP/hr at 95.6% recall on held-out noise. +PATIENCE = int(os.getenv("WAKEWORD_PATIENCE", "2")) +# Per-word overrides, e.g. WAKEWORD_THRESHOLDS=hermes:0.95 (single short words are +# FP-prone, so they typically need a higher threshold/patience than a phrase). +WAKEWORD_THRESHOLDS = os.getenv("WAKEWORD_THRESHOLDS", "") +WAKEWORD_PATIENCES = os.getenv("WAKEWORD_PATIENCES", "") +# Explicit per-word verifier overrides, e.g. hermes:/app/models/hermes_verifier.npz. +WAKEWORD_VERIFIERS = os.getenv("WAKEWORD_VERIFIERS", "") +_vt = os.getenv("WAKEWORD_VERIFIER_THRESHOLD", "") +VERIFIER_THRESHOLD = float(_vt) if _vt else None + +DEBOUNCE_SECS = float(os.getenv("WAKEWORD_DEBOUNCE_SECS", "3.0")) +VAD_THRESHOLD = float(os.getenv("WAKEWORD_VAD_THRESHOLD", "0.5")) +# End-of-turn is decided by the Smart Turn MODEL at each speech->silence pause +# (eot_min_silence -> first query, eot_recheck -> re-query cadence). stop_secs is +# now only a LONG backstop for when the model never fires (pure trailing silence). +STOP_SECS = float(os.getenv("WAKEWORD_STOP_SECS", "6.0")) +EOT_MIN_SILENCE_SECS = float(os.getenv("WAKEWORD_EOT_MIN_SILENCE_SECS", "0.2")) +EOT_RECHECK_SECS = float(os.getenv("WAKEWORD_EOT_RECHECK_SECS", "0.3")) +MAX_ARM_SECS = float(os.getenv("WAKEWORD_MAX_ARM_SECS", "15.0")) +# Minimum spoken (VAD) speech a captured command must contain to be sent for +# batch ASR. Below this the capture is a near-silent false arm; the backend skips +# transcription so self-diarizing ASR can't hallucinate a phantom command. +MIN_COMMAND_SPEECH_SECS = float(os.getenv("WAKEWORD_MIN_COMMAND_SPEECH_SECS", "0.3")) +SERVICE_PORT = int(os.getenv("WAKEWORD_SERVICE_PORT", "8770")) +# Root for the data-collection clip store (mounted volume in docker-compose). +DATA_DIR = os.getenv("WAKEWORD_DATA_DIR", "/app/data/samples") +# "Prime + say it" capture tuning (data collection). Hard upper bound of 10 s +# on the whole priming session; the utterance itself caps at PRIME_MAX_SECS and +# normally ends a beat after a trailing-silence gap (so capture lands in ~5 s). +PRIME_TIMEOUT_SECS = float(os.getenv("WAKEWORD_PRIME_TIMEOUT_SECS", "10.0")) +PRIME_TRAIL_SILENCE_SECS = float(os.getenv("WAKEWORD_PRIME_TRAIL_SILENCE_SECS", "0.6")) +PRIME_MAX_SECS = float(os.getenv("WAKEWORD_PRIME_MAX_SECS", "4.0")) +# VAD gate while priming — dropped well below the live VAD threshold so the +# known-incoming utterance is auto-caught even when spoken softly. +PRIME_VAD_THRESHOLD = float(os.getenv("WAKEWORD_PRIME_VAD_THRESHOLD", "0.3")) +# Optional explicit ONNX paths (vendored into models/). Empty -> pipecat bundle. +SMART_TURN_MODEL_PATH = os.getenv("SMART_TURN_MODEL_PATH", "") or None +SILERO_VAD_MODEL_PATH = os.getenv("SILERO_VAD_MODEL_PATH", "") or None + + +def _parse_models(spec: str) -> dict[str, str]: + """Parse ``WAKEWORD_MODELS`` into an ordered ``{name: abs_model_path}``.""" + models: dict[str, str] = {} + for item in spec.split(","): + item = item.strip() + if not item: + continue + if ":" in item: + name, file = (p.strip() for p in item.split(":", 1)) + else: + file = item + name = os.path.splitext(os.path.basename(file))[0] + path = file if os.path.isabs(file) else os.path.join(MODELS_DIR, file) + models[name] = path + return models + + +def _parse_kv(spec: str, cast) -> dict: + """Parse a ``name:value,name:value`` spec into a typed dict.""" + out: dict = {} + for item in spec.split(","): + item = item.strip() + if ":" in item: + k, v = (p.strip() for p in item.split(":", 1)) + out[k] = cast(v) + return out + + +MODELS = _parse_models(WAKEWORD_MODELS) +WAKEWORDS = list(MODELS.keys()) +THRESHOLDS = _parse_kv(WAKEWORD_THRESHOLDS, float) +PATIENCES = _parse_kv(WAKEWORD_PATIENCES, int) +COLLECT_ONLY = [w.strip() for w in WAKEWORD_COLLECT_ONLY.split(",") if w.strip()] + +# Runtime collect-only overrides set from the Wake-Word Lab UI are persisted here +# (in the mounted ./data volume, so they survive restarts and work even when the +# backend runs on a different machine and can't reach the compose .env). Once this +# file exists it is authoritative over WAKEWORD_COLLECT_ONLY, which is then just the +# initial default for a fresh deployment. +COLLECT_ONLY_STATE_PATH = os.path.join( + os.path.dirname(DATA_DIR.rstrip("/")), "collect_only.json" +) + + +def _initial_collect_only() -> list[str]: + """Collect-only words at startup: persisted UI override if present, else the + env default. Filtered to currently-configured words (a word dropped from + WAKEWORD_MODELS is silently ignored).""" + words = COLLECT_ONLY + if os.path.exists(COLLECT_ONLY_STATE_PATH): + try: + with open(COLLECT_ONLY_STATE_PATH) as fh: + data = json.load(fh) + if isinstance(data, list): + words = [str(w) for w in data] + except (OSError, ValueError) as e: + logger.warning( + f"could not read collect-only state {COLLECT_ONLY_STATE_PATH}: {e} " + f"— falling back to WAKEWORD_COLLECT_ONLY" + ) + return [w for w in words if w in MODELS] + + +def _save_collect_only(words) -> None: + """Persist the current collect-only set so it survives a restart.""" + with open(COLLECT_ONLY_STATE_PATH, "w") as fh: + json.dump(sorted(words), fh) + + +# Runtime per-word OFF overrides from the UI. Disabled words neither dispatch +# nor emit collect-only shadow events. Persisted in the same mounted data dir. +DISABLED_STATE_PATH = os.path.join( + os.path.dirname(DATA_DIR.rstrip("/")), "disabled.json" +) + + +def _initial_disabled() -> list[str]: + """Words disabled at startup from persisted state (or none).""" + if os.path.exists(DISABLED_STATE_PATH): + try: + with open(DISABLED_STATE_PATH) as fh: + data = json.load(fh) + if isinstance(data, list): + return [str(w) for w in data if w in MODELS] + except (OSError, ValueError) as e: + logger.warning( + f"could not read disabled state {DISABLED_STATE_PATH}: {e} " + f"— defaulting to all enabled" + ) + return [] + + +def _save_disabled(words) -> None: + """Persist the current disabled set so it survives a restart.""" + with open(DISABLED_STATE_PATH, "w") as fh: + json.dump(sorted(words), fh) + + +# Runtime verifier on/off overrides from the Lab UI, persisted alongside the +# collect-only state (same ./data volume, same restart/cross-machine semantics). +# Stores the set of words whose verifier is toggled OFF; a word absent here keeps +# its verifier ON (when one is loaded). Authoritative over defaults once written. +VERIFIER_DISABLED_STATE_PATH = os.path.join( + os.path.dirname(DATA_DIR.rstrip("/")), "verifier_disabled.json" +) + + +def _initial_verifier_disabled() -> list[str]: + """Words with their verifier disabled at startup: the persisted UI override if + present (filtered to currently-configured words), else none.""" + if os.path.exists(VERIFIER_DISABLED_STATE_PATH): + try: + with open(VERIFIER_DISABLED_STATE_PATH) as fh: + data = json.load(fh) + if isinstance(data, list): + return [str(w) for w in data if w in MODELS] + except (OSError, ValueError) as e: + logger.warning( + f"could not read verifier-disabled state " + f"{VERIFIER_DISABLED_STATE_PATH}: {e} — defaulting to all enabled" + ) + return [] + + +def _save_verifier_disabled(words) -> None: + """Persist the current verifier-disabled set so it survives a restart.""" + with open(VERIFIER_DISABLED_STATE_PATH, "w") as fh: + json.dump(sorted(words), fh) + + +def _resolve_verifiers() -> dict[str, str]: + """Per-word verifier paths: explicit override, else ``_verifier.npz`` if + it exists. Words with no verifier file (and no override) get no entry.""" + overrides = _parse_kv(WAKEWORD_VERIFIERS, str) + out: dict[str, str] = {} + for name in WAKEWORDS: + if name in overrides: + out[name] = overrides[name] + continue + default = os.path.join(MODELS_DIR, f"{name}_verifier.npz") + if os.path.exists(default): + out[name] = default + return out + + +VERIFIERS = _resolve_verifiers() + + +def _validate_models() -> None: + """Refuse to start unless every configured wake model file exists on disk.""" + available = ( + sorted(f for f in os.listdir(MODELS_DIR) if f.endswith((".onnx", ".pt"))) + if os.path.isdir(MODELS_DIR) + else [] + ) + required = dict(MODELS) + if SMART_TURN_MODEL_PATH: + required["smart-turn"] = SMART_TURN_MODEL_PATH + if SILERO_VAD_MODEL_PATH: + required["silero-vad"] = SILERO_VAD_MODEL_PATH + missing = {name: p for name, p in required.items() if not os.path.exists(p)} + if missing: + details = "; ".join(f"{name} '{p}'" for name, p in missing.items()) + raise FileNotFoundError( + f"Configured model(s) not found on disk: {details}. " + f"Available .onnx in {MODELS_DIR}: {available or '(none)'}. " + f"Train/vendor the model and set WAKEWORD_MODELS accordingly." + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Load the detector and start the background consumer.""" + if not MODELS: + raise RuntimeError("WAKEWORD_MODELS is empty — configure at least one word") + _validate_models() + + detector = HermesDetector( + models=MODELS, + threshold=THRESHOLD, + patience=PATIENCE, + thresholds=THRESHOLDS, + patiences=PATIENCES, + debounce_secs=DEBOUNCE_SECS, + vad_threshold=VAD_THRESHOLD, + stop_secs=STOP_SECS, + max_arm_secs=MAX_ARM_SECS, + eot_min_silence_secs=EOT_MIN_SILENCE_SECS, + eot_recheck_secs=EOT_RECHECK_SECS, + smart_turn_model_path=SMART_TURN_MODEL_PATH, + silero_vad_model_path=SILERO_VAD_MODEL_PATH, + prime_timeout_secs=PRIME_TIMEOUT_SECS, + prime_trail_silence_secs=PRIME_TRAIL_SILENCE_SECS, + prime_max_secs=PRIME_MAX_SECS, + prime_vad_threshold=PRIME_VAD_THRESHOLD, + min_command_speech_secs=MIN_COMMAND_SPEECH_SECS, + verifiers=VERIFIERS, + verifier_threshold=VERIFIER_THRESHOLD, + collect_only=_initial_collect_only(), + verifiers_disabled=_initial_verifier_disabled(), + disabled=_initial_disabled(), + ) + app.state.detector = detector + store = SampleStore(DATA_DIR, WAKEWORDS, legacy_wakeword=LEGACY_WAKEWORD) + app.state.store = store + consumer = WakeWordConsumer( + detector=detector, redis_url=REDIS_URL, sample_store=store + ) + app.state.consumer = consumer + consumer_task = asyncio.create_task(consumer.start()) + app.state.consumer_task = consumer_task + logger.info( + f"Wake-word service ready (words={', '.join(WAKEWORDS)}, group={GROUP_NAME})" + ) + + try: + yield + finally: + await consumer.stop() + consumer_task.cancel() + try: + await consumer_task + except asyncio.CancelledError: + pass + + +app = FastAPI(title="Hermes Wake-Word Service", lifespan=lifespan) + + +def _collect_only_now() -> set: + """The live collect-only set (the detector's, once started) so summaries + reflect runtime UI toggles, not just the boot-time env/state.""" + detector = getattr(app.state, "detector", None) + return set(detector.collect_only) if detector is not None else set(COLLECT_ONLY) + + +def _verifier_disabled_now() -> set: + """The live set of words whose verifier is toggled OFF (the detector's, once + started), so summaries reflect runtime UI toggles.""" + detector = getattr(app.state, "detector", None) + return set(detector.verifiers_disabled) if detector is not None else set() + + +def _disabled_now() -> set: + """The live set of disabled wake words (runtime UI toggles).""" + detector = getattr(app.state, "detector", None) + return set(detector.disabled) if detector is not None else set() + + +def _wakeword_summaries() -> list[dict]: + """Per-word config summary for the dashboard.""" + collect_only = _collect_only_now() + verifier_disabled = _verifier_disabled_now() + disabled = _disabled_now() + out = [] + for name in WAKEWORDS: + has_verifier = name in VERIFIERS and os.path.exists(VERIFIERS[name]) + out.append( + { + "name": name, + "model": os.path.basename(MODELS[name]), + # ``verifier`` = a verifier file is loaded for this word (capability); + # ``verifier_enabled`` = it is currently consulted (the UI toggle). + "verifier": has_verifier, + "verifier_enabled": has_verifier and name not in verifier_disabled, + "threshold": THRESHOLDS.get(name, THRESHOLD), + "patience": PATIENCES.get(name, PATIENCE), + "collect_only": name in collect_only, + "disabled": name in disabled, + } + ) + return out + + +@app.get("/health") +async def health(response: Response): + """Liveness of the actual work loop, not just the HTTP server. + + The consumer runs as a background task. If it dies (e.g. an unrecoverable + error in the discovery loop), uvicorn keeps serving requests — so reporting + "ok" purely on the HTTP server being up would mask a service that no longer + consumes any audio. Reflect the real consumer state and return 503 when it + is not alive, so orchestrators / the dashboard see the failure. + """ + consumer: WakeWordConsumer | None = getattr(app.state, "consumer", None) + task: asyncio.Task | None = getattr(app.state, "consumer_task", None) + consumer_alive = bool( + consumer is not None + and consumer.running + and task is not None + and not task.done() + ) + status_str = "ok" if consumer_alive else "unhealthy" + if not consumer_alive: + response.status_code = 503 + return { + "status": status_str, + "consumer_alive": consumer_alive, + "wakewords": _wakeword_summaries(), + "redis_url": REDIS_URL, + "consumer_group": GROUP_NAME, + "detections_stream": DETECTIONS_STREAM, + } + + +@app.get("/status") +async def status(): + """Active stream count from the running consumer.""" + consumer: WakeWordConsumer = app.state.consumer + active = sum(1 for t in consumer._stream_tasks.values() if not t.done()) + return { + "running": consumer.running, + "active_streams": active, + "known_streams": list(consumer._stream_tasks.keys()), + } + + +@app.get("/models") +async def models(): + """Configured wake words (for the dashboard picker + the Wake-Word Lab). + + ``available``/``active`` are kept for the existing acoustic-condition picker + (now the list of wake-word NAMES); ``wakewords`` carries the richer per-word + config the Lab uses to render one section per word. + """ + return { + "available": WAKEWORDS, + "active": WAKEWORDS[0] if WAKEWORDS else None, + "wakewords": _wakeword_summaries(), + } + + +# --------------------------------------------------------------------------- # +# Data-collection API (the training flywheel): prime positive capture, review +# captured clips, label them true/false positive — all scoped per wake word. +# Consumed by the backend proxy at /api/wakeword/* — see backends/advanced +# .../routers/modules/wakeword_routes.py +# --------------------------------------------------------------------------- # + + +class PrimeRequest(BaseModel): + client_id: str + wakeword: str + + +class UnprimeRequest(BaseModel): + client_id: str + + +class LabelRequest(BaseModel): + label: str # "wake" -> positive, "not_wake" -> negative + + +class CollectOnlyRequest(BaseModel): + wakeword: str + collect_only: bool # True -> shadow (farm-only), False -> normal dispatch word + + +class VerifierEnabledRequest(BaseModel): + wakeword: str + enabled: bool # True -> consult the second-stage verifier, False -> stage-1 only + + +class DisabledRequest(BaseModel): + wakeword: str + disabled: bool # True -> fully off for this word; False -> enabled + + +def _require_wakeword(wakeword: str) -> None: + if wakeword not in MODELS: + raise HTTPException( + status_code=400, + detail=f"unknown wake word '{wakeword}' (have {WAKEWORDS})", + ) + + +@app.post("/collect_only") +async def set_collect_only(req: CollectOnlyRequest): + """Toggle a wake word between normal dispatch and collect-only (shadow) mode. + + Collect-only words fire live to farm false-positive review data but never + dispatch a command / play a tone / block a real wake word. Mutates the running + detector's live set (effective on the next frame) and persists the choice so it + survives a restart. Returns the refreshed per-word summaries. + """ + _require_wakeword(req.wakeword) + detector: HermesDetector = app.state.detector + if req.collect_only: + detector.collect_only.add(req.wakeword) + else: + detector.collect_only.discard(req.wakeword) + _save_collect_only(detector.collect_only) + logger.info( + f"collect-only {'ON' if req.collect_only else 'OFF'} for '{req.wakeword}' " + f"(now: {sorted(detector.collect_only) or '(none)'})" + ) + return {"wakewords": _wakeword_summaries()} + + +@app.post("/verifier_enabled") +async def set_verifier_enabled(req: VerifierEnabledRequest): + """Toggle a wake word's second-stage verifier on/off at runtime. + + The verifier stays LOADED either way — disabling only skips its check so arms + dispatch on the stage-1 acoustic model alone. Useful to A/B the verifier's + false-positive suppression, or to fall back to stage-1 if a verifier regresses. + Mutates the running detector (effective next frame) and persists the choice. + Has no effect for a word with no verifier loaded. Returns refreshed summaries. + """ + _require_wakeword(req.wakeword) + detector: HermesDetector = app.state.detector + if req.enabled: + detector.verifiers_disabled.discard(req.wakeword) + else: + detector.verifiers_disabled.add(req.wakeword) + _save_verifier_disabled(detector.verifiers_disabled) + logger.info( + f"verifier {'ON' if req.enabled else 'OFF'} for '{req.wakeword}' " + f"(disabled now: {sorted(detector.verifiers_disabled) or '(none)'})" + ) + return {"wakewords": _wakeword_summaries()} + + +@app.post("/disabled") +async def set_disabled(req: DisabledRequest): + """Toggle a wake word fully off/on at runtime. + + Disabled words neither dispatch commands nor emit collect-only shadow events. + Effective immediately and persisted across restarts. + """ + _require_wakeword(req.wakeword) + detector: HermesDetector = app.state.detector + if req.disabled: + detector.disabled.add(req.wakeword) + else: + detector.disabled.discard(req.wakeword) + _save_disabled(detector.disabled) + logger.info( + f"disabled {'ON' if req.disabled else 'OFF'} for '{req.wakeword}' " + f"(disabled now: {sorted(detector.disabled) or '(none)'})" + ) + return {"wakewords": _wakeword_summaries()} + + +@app.get("/streams") +async def streams(): + """Active audio streams the UI can prime for a positive capture.""" + consumer: WakeWordConsumer = app.state.consumer + return {"streams": consumer.active_clients()} + + +@app.post("/prime") +async def prime(req: PrimeRequest): + """Arm a one-shot positive capture of ``wakeword`` on a streaming client. + + The next utterance on that stream is saved as a labeled positive for that + word regardless of model score (the false-negative / hard-positive path). + """ + _require_wakeword(req.wakeword) + consumer: WakeWordConsumer = app.state.consumer + if not consumer.prime(req.client_id, req.wakeword): + raise HTTPException( + status_code=404, + detail=f"No active stream '{req.client_id}' to prime", + ) + return {"client_id": req.client_id, "wakeword": req.wakeword, "primed": True} + + +@app.post("/unprime") +async def unprime(req: UnprimeRequest): + """Manually end an in-progress prime capture (the UI 'stop' button). + + Finalizes whatever was heard so far and saves it for review — the capture is + never silently dropped. + """ + consumer: WakeWordConsumer = app.state.consumer + if not consumer.unprime(req.client_id): + raise HTTPException( + status_code=404, + detail=f"No priming stream '{req.client_id}' to stop", + ) + return {"client_id": req.client_id, "unprimed": True} + + +@app.get("/samples") +async def list_samples(wakeword: str = Query(...), bucket: str = Query("pending")): + """List captured clips in a wake word's bucket (pending/positive/negative).""" + _require_wakeword(wakeword) + if bucket not in BUCKETS: + raise HTTPException(status_code=400, detail=f"bad bucket (expected {BUCKETS})") + store: SampleStore = app.state.store + return { + "wakeword": wakeword, + "bucket": bucket, + "samples": store.list(wakeword, bucket), + } + + +@app.get("/samples/stats") +async def sample_stats(): + """Per-wake-word, per-bucket clip counts for the data-collection dashboard.""" + store: SampleStore = app.state.store + return store.stats() + + +@app.post("/samples/dedupe") +async def dedupe_samples(wakeword: str = Query(...)): + """Remove exact-duplicate clips within a wake word (across all buckets). + + Keeps one representative per identical-audio group (a labeled clip over a + pending one), deleting the rest — including duplicate pending clips. + """ + _require_wakeword(wakeword) + store: SampleStore = app.state.store + return store.dedupe(wakeword) + + +@app.get("/samples/{clip_id}/audio") +async def sample_audio(clip_id: str): + """Return a clip's WAV bytes for in-browser playback.""" + store: SampleStore = app.state.store + path = store.wav_path(clip_id) + if path is None: + raise HTTPException(status_code=404, detail="clip not found") + with open(path, "rb") as fh: + data = fh.read() + return Response(content=data, media_type="audio/wav") + + +@app.post("/samples/{clip_id}/label") +async def label_sample(clip_id: str, req: LabelRequest): + """Apply a review label, moving the clip into positive/negative.""" + store: SampleStore = app.state.store + try: + return store.label(clip_id, req.label) + except KeyError: + raise HTTPException(status_code=404, detail="clip not found") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.post("/samples/{clip_id}/move") +async def move_sample( + clip_id: str, wakeword: str = Query(...), bucket: str = Query(PENDING) +): + """Move a clip to a different wake word's bucket (default pending). + + For the acoustic-overlap case: a live arm attributed to the priority word that + is really another word's utterance (e.g. a bare "hermes" that landed in + hey_hermes). Harvests it into the right word as real-usage training data. + """ + _require_wakeword(wakeword) + if bucket not in BUCKETS: + raise HTTPException(status_code=400, detail=f"bad bucket (expected {BUCKETS})") + store: SampleStore = app.state.store + try: + return store.move(clip_id, wakeword, bucket) + except KeyError: + raise HTTPException(status_code=404, detail="clip not found") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.post("/samples/{clip_id}/copy") +async def copy_sample( + clip_id: str, wakeword: str = Query(...), bucket: str = Query(PENDING) +): + """Copy a clip into another wake word's bucket (source stays). + + For a false positive that fired several words — it's a hard negative for each, + so fan it out instead of moving it. + """ + _require_wakeword(wakeword) + if bucket not in BUCKETS: + raise HTTPException(status_code=400, detail=f"bad bucket (expected {BUCKETS})") + store: SampleStore = app.state.store + try: + return store.copy(clip_id, wakeword, bucket) + except KeyError: + raise HTTPException(status_code=404, detail="clip not found") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.delete("/samples/{clip_id}") +async def delete_sample(clip_id: str): + """Delete a clip (WAV + metadata).""" + store: SampleStore = app.state.store + if not store.delete(clip_id): + raise HTTPException(status_code=404, detail="clip not found") + return {"deleted": clip_id} + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=SERVICE_PORT) diff --git a/extras/wakeword-service/build_nanowakeword.sh b/extras/wakeword-service/build_nanowakeword.sh new file mode 100755 index 00000000..97c5a7bd --- /dev/null +++ b/extras/wakeword-service/build_nanowakeword.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Stage a nanowakeword wheel into vendor/ for the Docker build. Source of truth +# is the FORK https://github.com/AnkushMalaker/nanowakeword (carries the +# determinism fixes: deduped optimizer params, NWW_SEED/NWW_DETERMINISTIC). +# PyPI tops out at 2.1.3 and upstream 2.1.4 lacks the fixes. +# Run this BEFORE `docker compose build`. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SRC="$HERE/../../untracked/nanowakeword" +VENDOR="$HERE/vendor" +FORK_URL="https://github.com/AnkushMalaker/nanowakeword" + +if [ ! -d "$SRC" ]; then + echo "Cloning fork into $SRC ..." + git clone "$FORK_URL" "$SRC" +fi +echo "Building from: $(git -C "$SRC" log -1 --oneline) ($(git -C "$SRC" remote get-url origin))" + +mkdir -p "$VENDOR" +rm -f "$VENDOR"/nanowakeword-*.whl +echo "Building nanowakeword wheel from $SRC ..." +uv build --wheel --project "$SRC" --out-dir "$VENDOR" +echo "Staged:" +ls -1 "$VENDOR"/nanowakeword-*.whl +echo "Now run: docker compose build" diff --git a/extras/wakeword-service/consumer.py b/extras/wakeword-service/consumer.py new file mode 100644 index 00000000..895625d2 --- /dev/null +++ b/extras/wakeword-service/consumer.py @@ -0,0 +1,567 @@ +"""Redis stream consumer for the Hermes wake-word service. + +Consumes ``audio:stream:*`` with a dedicated consumer group +``wakeword_detection`` (registered in the backend's ``_EXPECTED_GROUPS`` so the +streaming consumer won't delete streams out from under our pending entries). + +For each client stream it maintains per-client wake state in +:class:`HermesDetector`. On a captured turn it resolves the command text from +the existing ``transcription:results:{session_id}`` stream (no second ASR) and +publishes a ``wake_word.detected`` message to the ``wakeword:detections`` stream +for the backend-side dispatcher to forward to the Hermes plugin. +""" + +import asyncio +import base64 +import json +import logging +import os +import time +from pathlib import Path +from typing import Dict + +import redis.asyncio as redis +from detector import ( + RECEPTIVE_FIELD_SECONDS, + SAMPLE_RATE, + ClientWakeState, + HermesDetector, + WakeEvent, +) +from redis import exceptions as redis_exceptions +from samples import PENDING, SampleStore + +logger = logging.getLogger(__name__) + +STREAM_PATTERN = "audio:stream:*" +GROUP_NAME = "wakeword_detection" +DETECTIONS_STREAM = "wakeword:detections" + +# Notification tones (HA Voice PE sounds, CC-BY 4.0 — see tones/LICENSE.md). +# This service is the single source of tone audio: tones are sent to every client +# (HAVPE relay, phone app, web UI) as inline ``play-audio`` bytes, which they all +# decode and play. No client bundles its own copy. +_TONES_DIR = Path(__file__).resolve().parent / "tones" +_TONE_FILES = {"armed": "armed.wav", "done": "done.wav"} # logical name -> file + + +def _load_tones() -> Dict[str, str]: + """Load each tone as a base64 ``play-audio`` payload once at import.""" + loaded: Dict[str, str] = {} + for name, filename in _TONE_FILES.items(): + path = _TONES_DIR / filename + try: + loaded[name] = base64.b64encode(path.read_bytes()).decode("ascii") + except OSError as e: # noqa: BLE001 - a missing tone must not break detection + logger.warning("Tone '%s' unavailable (%s): %s", name, path, e) + return loaded + + +_TONE_B64 = _load_tones() + +# Stop processing a stream after this long with no new chunks (zombie guard). +STREAM_IDLE_TIMEOUT_SECONDS = 300 + +# Dev: persist the interpreter buffer state at arm (embeddings + ~10 s raw audio) +# alongside each captured clip, so a false positive is exactly reproducible +# offline. On by default; set WAKEWORD_SAVE_BUFFER_STATE=0 to disable. +SAVE_BUFFER_STATE = os.getenv("WAKEWORD_SAVE_BUFFER_STATE", "1").lower() not in ( + "0", + "false", + "no", +) + + +class WakeWordConsumer: + """Discovers audio streams and runs acoustic wake detection on each.""" + + def __init__( + self, detector: HermesDetector, redis_url: str, sample_store: SampleStore + ): + """Initialize the consumer. + + Args: + detector: Loaded :class:`HermesDetector`. + redis_url: Redis connection URL (shared with the backend). + sample_store: Store for captured wake-word clips (the training loop). + """ + self.detector = detector + self.redis_url = redis_url + self.sample_store = sample_store + self.redis_client: redis.Redis | None = None + self.consumer_name = f"wakeword-worker-{os.getpid()}" + self.running = False + # client_id -> asyncio.Task processing that stream + self._stream_tasks: Dict[str, asyncio.Task] = {} + # client_id -> live wake state, so HTTP handlers can prime a stream. + self._states: Dict[str, ClientWakeState] = {} + + def active_clients(self) -> list[dict]: + """List currently-processing streams (for the data-collection UI).""" + out = [] + for client_id, task in self._stream_tasks.items(): + if task.done(): + continue + state = self._states.get(client_id) + out.append( + { + "client_id": client_id, + "priming": bool(state and state.priming), + "prime_wakeword": ( + state.prime_wakeword if state and state.priming else None + ), + "armed": bool(state and state.armed), + } + ) + return out + + def prime(self, client_id: str, wakeword: str) -> bool: + """Arm a one-shot positive capture of ``wakeword`` on an active stream. + + Returns False if the stream is unknown. Raises ValueError if ``wakeword`` + is not a configured wake word. + """ + state = self._states.get(client_id) + task = self._stream_tasks.get(client_id) + if state is None or task is None or task.done(): + return False + self.detector.start_priming(state, client_id, wakeword) + return True + + def unprime(self, client_id: str) -> bool: + """Manually end an in-progress prime capture (UI 'stop'). False if unknown. + + The per-stream task finalizes and saves on its next frame, so the captured + attempt always lands in the review queue rather than being dropped. + """ + state = self._states.get(client_id) + task = self._stream_tasks.get(client_id) + if state is None or task is None or task.done(): + return False + self.detector.stop_priming(state) + return True + + async def start(self) -> None: + """Connect to Redis and run the discovery + processing loop.""" + self.redis_client = redis.from_url(self.redis_url) + self.running = True + logger.info( + f"WakeWordConsumer started (group={GROUP_NAME}, redis={self.redis_url})" + ) + try: + while self.running: + # A transient Redis failure (e.g. Redis restarting during a stack + # restart) must NOT permanently kill the discovery loop — otherwise + # the HTTP server stays up reporting "healthy" while no audio is ever + # consumed again. Catch, log, back off, and retry; the redis.asyncio + # connection pool reconnects on the next command. + try: + await self._discover_and_spawn() + await asyncio.sleep(2.0) + except asyncio.CancelledError: + raise + except redis_exceptions.RedisError as e: + logger.warning( + f"Redis error in discovery loop (retrying in 2s): {e}" + ) + await asyncio.sleep(2.0) + except Exception as e: # noqa: BLE001 - loop must never die silently + logger.error( + f"Unexpected error in discovery loop (retrying in 2s): {e}", + exc_info=True, + ) + await asyncio.sleep(2.0) + finally: + await self._shutdown() + + async def stop(self) -> None: + """Signal the consumer to stop.""" + self.running = False + + async def _discover_and_spawn(self) -> None: + streams = await self._discover_streams() + live_clients: set[str] = set() + for stream_name in streams: + client_id = stream_name.replace("audio:stream:", "") + # A device that drops without a clean end-marker leaves its + # audio:stream key behind (session stuck "active"). Without this + # check we'd re-spawn a task for that dead key every time the + # previous one idled out, so it perpetually shows as an "active + # stream". Only process streams that got a chunk recently. + if not await self._stream_is_live(stream_name): + continue + live_clients.add(client_id) + task = self._stream_tasks.get(client_id) + if task is None or task.done(): + if task is not None and task.done(): + # Surface any exception from the finished task. + exc = task.exception() + if exc is not None: + logger.error(f"Stream task for '{client_id}' failed: {exc}") + self._stream_tasks[client_id] = asyncio.create_task( + self._process_stream(stream_name, client_id) + ) + # Reap tasks whose stream is gone or has gone stale, so they stop being + # reported as active streams (and free their per-client detector state). + for client_id, task in list(self._stream_tasks.items()): + if task.done(): + self._stream_tasks.pop(client_id, None) + elif client_id not in live_clients: + logger.info(f"Reaping wake stream task for stale '{client_id}'") + task.cancel() + self._stream_tasks.pop(client_id, None) + + async def _stream_is_live(self, stream_name: str) -> bool: + """True if the stream received a chunk within the idle window. + + Redis stream entry ids are wall-clock-ms based (server-assigned on XADD), + so the last entry's id tells us how long ago audio last arrived — the + signal that distinguishes a live stream from an abandoned one whose key + Redis still holds. + """ + try: + entries = await self.redis_client.xrevrange(stream_name, count=1) + except redis_exceptions.ResponseError: + return False + if not entries: + return False + last_id = entries[0][0] + last_id = last_id.decode() if isinstance(last_id, bytes) else last_id + try: + ts_ms = int(last_id.split("-")[0]) + except (ValueError, IndexError): + return False + return (time.time() * 1000 - ts_ms) < STREAM_IDLE_TIMEOUT_SECONDS * 1000 + + async def _discover_streams(self) -> list[str]: + streams: list[str] = [] + cursor = b"0" + while cursor: + cursor, keys = await self.redis_client.scan( + cursor, match=STREAM_PATTERN, count=100 + ) + streams.extend(k.decode() if isinstance(k, bytes) else k for k in keys) + return streams + + async def _setup_group(self, stream_name: str) -> None: + try: + await self.redis_client.xgroup_create( + stream_name, GROUP_NAME, "0", mkstream=True + ) + logger.debug(f"Created group {GROUP_NAME} for {stream_name}") + except redis_exceptions.ResponseError as e: + if "BUSYGROUP" not in str(e): + raise + + async def _process_stream(self, stream_name: str, client_id: str) -> None: + await self._setup_group(stream_name) + state = self.detector.new_client_state() + self._states[client_id] = state + session_id = client_id # session_id == client_id in this pipeline + last_activity = time.time() + logger.info(f"▶ Processing wake stream '{stream_name}'") + + try: + while self.running: + messages = await self.redis_client.xreadgroup( + GROUP_NAME, + self.consumer_name, + {stream_name: ">"}, + count=10, + block=1000, + ) + + if not messages: + if time.time() - last_activity > STREAM_IDLE_TIMEOUT_SECONDS: + await self._flush(state, client_id, session_id) + logger.info( + f"Stream '{stream_name}' idle — ending wake processing" + ) + return + continue + + for _stream, stream_messages in messages: + for message_id, fields in stream_messages: + msg_id = ( + message_id.decode() + if isinstance(message_id, bytes) + else message_id + ) + try: + if fields.get(b"end_marker") or fields.get("end_marker"): + await self.redis_client.xack( + stream_name, GROUP_NAME, msg_id + ) + await self._flush(state, client_id, session_id) + logger.info(f"End marker on '{stream_name}' — ending") + return + + pcm = fields.get(b"audio_data") or fields.get("audio_data") + if pcm: + last_activity = time.time() + was_armed = state.armed + event = await self.detector.process_frame( + state, client_id, session_id, pcm + ) + # Real acoustic arm transition -> push an immediate + # UI pulse (skip deliberate training primes). + if state.armed and not was_armed and not state.priming: + await self._on_armed(state, client_id, session_id) + if event is not None: + await self._handle_event(event) + finally: + await self.redis_client.xack( + stream_name, GROUP_NAME, msg_id + ) + finally: + self._states.pop(client_id, None) + + async def _flush(self, state, client_id: str, session_id: str) -> None: + """Finalize an armed-but-uncaptured turn when the stream ends/goes idle.""" + event = self.detector.flush(state, client_id, session_id) + if event is not None: + await self._handle_event(event) + + async def _handle_event(self, event: WakeEvent) -> None: + """Route a captured event: persist training data and (for real arms) + dispatch the command to the Hermes plugin via Redis.""" + if event.kind == "primed_positive": + # "Prime + say it" capture -> review queue (pending), same as a real + # arm, so the user confirms wake / not-wake before it rolls into + # training. Rescued false-negatives become positives once labeled. + # Off-loop: the disk write must not stall the per-client frame loop. + await asyncio.to_thread(self._save_sample, PENDING, event, event.audio) + return + # Real acoustic arm. Play the end-of-listening tone FIRST — it's a pure ack + # that needs only client_id, so it must never wait behind the disk write / + # user lookup / SSE / XADD below (a slow/near-full disk would otherwise make + # the tone lag). Collect-only (shadow) arms farm FP data silently: no tone, + # no command dispatch. + if not getattr(event, "collect_only", False): + await self._send_tone(event.client_id, "done") + # Snapshot the trigger window for false-positive review. Off-loop so the + # synchronous WAV/JSON writes don't block the frame loop. + await asyncio.to_thread(self._save_sample, PENDING, event, event.trigger_audio) + if getattr(event, "collect_only", False): + return + await self._publish_detection(event) + + def _save_sample(self, bucket: str, event: WakeEvent, pcm: bytes) -> None: + """Persist a captured clip into the on-disk training store, scoped to the + event's wake word.""" + if not pcm: + return + meta = { + "client_id": event.client_id, + "session_id": event.session_id, + "score": round(event.score, 4), + "reason": event.reason, + "kind": event.kind, + "also_fired": list(event.also_fired), + "collect_only": getattr(event, "collect_only", False), + "source": ( + "prime" + if event.kind == "primed_positive" + else ("shadow" if getattr(event, "collect_only", False) else "arm") + ), + # The model's receptive field is ~1.96 s, and the arm fires at the END + # of the pre-roll, so the activation is the LAST ~1.96 s of this clip. + "activation_window_secs": RECEPTIVE_FIELD_SECONDS, + "activation_at": "end", + } + if event.kind == "primed_positive": + # Left UNLABELED so it surfaces in the pending review queue; the score + # still flags whether the live model under-scored this utterance. + meta["false_negative"] = event.is_false_negative + else: + # Real arm: record whether the captured turn held speech. A near-silent + # arm (has_speech=False) is a false positive whose command ASR the + # backend skipped — flagged here so it's visible in the review store. + meta["has_speech"] = event.has_speech + # Dev: attach the interpreter buffer state captured at arm so the FP is + # exactly reproducible offline (command arms only carry it). + features = event.buffer_features if SAVE_BUFFER_STATE else None + context_pcm = event.buffer_context if SAVE_BUFFER_STATE else None + try: + rec = self.sample_store.save( + event.wakeword, + bucket, + pcm, + SAMPLE_RATE, + int(time.time() * 1000), + meta, + features=features, + context_pcm=context_pcm, + ) + logger.info( + f"💾 saved {bucket} sample {rec['id']} ({len(pcm)}B" + f"{', +buffer-state' if rec.get('has_buffer_state') else ''})" + ) + except ( + Exception + ) as e: # noqa: BLE001 - data collection must never break dispatch + logger.error(f"Failed to save {bucket} sample: {e}", exc_info=True) + + async def _publish_detection(self, event: WakeEvent) -> None: + """Publish a wake_word.detected message carrying the captured command audio. + + The backend dispatcher batch-transcribes ``audio_b64`` for a higher-quality + command than the streaming transcript would give, and avoids fragile + timestamp alignment against the transcription results stream. + """ + user_id = await self._lookup_user_id(event.session_id) + # Immediate UI pulse for end-of-turn, before the (slower) batch ASR + plugin + # dispatch the backend does — keeps the live-recording feedback snappy. + await self._publish_sse( + user_id, + "wake.end_of_turn", + { + "client_id": event.client_id, + "session_id": event.session_id, + "reason": event.reason, + "duration": round(event.eot_time - event.arm_time, 2), + }, + ) + # Amber "Thinking" ring on LED-capable devices: capture is done, the backend + # is now batch-transcribing + dispatching. The executor refreshes this when + # dispatch actually starts; it reverts on its own if no command follows. + await self._publish_downlink( + event.client_id, + "led-control", + { + "effect": "Thinking", + "r": 1.0, + "g": 0.45, + "b": 0.0, + "brightness": 0.45, + "duration": 8.0, + }, + ) + # NOTE: the end-of-listening ("done") tone is played up front in + # _handle_event, before this bookkeeping, so it never lags under load. + payload = { + "client_id": event.client_id, + "session_id": event.session_id, + "user_id": user_id, + "wakeword": event.wakeword, + "also_fired": list(event.also_fired), + "score": round(event.score, 4), + "reason": event.reason, + "sample_rate": SAMPLE_RATE, + "audio_b64": base64.b64encode(event.audio).decode("ascii"), + "has_speech": event.has_speech, + "detected_at": time.time(), + } + await self.redis_client.xadd( + DETECTIONS_STREAM, + {b"event": json.dumps(payload).encode()}, + maxlen=200, + approximate=True, + ) + logger.info( + f"📤 Published wake_word.detected for '{event.client_id}' " + f"({len(event.audio)}B audio, reason={event.reason})" + ) + + async def _on_armed( + self, state: ClientWakeState, client_id: str, session_id: str + ) -> None: + """Push a UI pulse the instant the wake word arms (before capture/ASR).""" + # Listening tone FIRST — a pure ack needing only client_id, so it never waits + # behind the user lookup / SSE below (keeps the cue instant under load). + await self._send_tone(client_id, "armed") + # Cyan "Listening" ring on LED-capable devices (HAVPE). Like the tone it only + # needs client_id, so it stays snappy; non-LED clients ignore the frame. + await self._publish_downlink( + client_id, + "led-control", + { + "effect": "Listening For Command", + "r": 0.09, + "g": 0.73, + "b": 0.95, + "brightness": 0.45, + "duration": 12.0, + }, + ) + user_id = await self._lookup_user_id(session_id) + await self._publish_sse( + user_id, + "wake.armed", + { + "client_id": client_id, + "session_id": session_id, + "score": round(getattr(state, "arm_score", 0.0), 4), + }, + ) + logger.info(f"🔔 wake.armed SSE for '{client_id}'") + + async def _send_tone(self, client_id: str, tone: str) -> None: + """Play a notification tone on the device via inline ``play-audio`` bytes. + + ``play-audio`` carries the tone bytes inline, so every client type (HAVPE + relay, phone app, web UI) can play it the same way — no client needs its own + bundled copy. Best-effort: a missing tone asset just means no sound. + """ + audio_b64 = _TONE_B64.get(tone) + if not audio_b64: + return + await self._publish_downlink( + client_id, + "play-audio", + {"audio_b64": audio_b64, "format": "wav", "announcement": True}, + ) + + async def _publish_downlink( + self, client_id: str, msg_type: str, data: dict + ) -> None: + """Push a control message to the device via ``device:downlink:{client_id}``. + + The backend's WebSocket handler subscribes to this channel and forwards the + frame down to the HAVPE relay, which plays it on the device. Best-effort — + a missing/audio-only device just ignores it. + """ + if not client_id: + return + try: + message = json.dumps({"type": msg_type, "data": data}) + await self.redis_client.publish(f"device:downlink:{client_id}", message) + except ( + Exception + ) as e: # noqa: BLE001 - downlink is best-effort, never break dispatch + logger.debug(f"Failed to publish downlink {msg_type}: {e}") + + async def _publish_sse(self, user_id: str, event_type: str, data: dict) -> None: + """Publish an SSE event to the user's channel (best-effort, never raises). + + Mirrors the backend ``sse_publisher`` wire format ({event, data, timestamp}) + so the ``/api/events/stream`` endpoint relays it to the browser unchanged. + """ + if not user_id: + return + try: + message = json.dumps( + {"event": event_type, "data": data, "timestamp": time.time()} + ) + await self.redis_client.publish(f"sse:{user_id}", message) + except ( + Exception + ) as e: # noqa: BLE001 - SSE is best-effort, never break dispatch + logger.debug(f"Failed to publish SSE {event_type}: {e}") + + async def _lookup_user_id(self, session_id: str) -> str: + """Read user_id from the session metadata hash.""" + try: + val = await self.redis_client.hget(f"audio:session:{session_id}", "user_id") + if val is not None: + return val.decode() if isinstance(val, bytes) else val + except Exception as e: # noqa: BLE001 + logger.warning(f"Could not read user_id for {session_id}: {e}") + return "" + + async def _shutdown(self) -> None: + for task in self._stream_tasks.values(): + task.cancel() + if self.redis_client is not None: + await self.redis_client.aclose() + logger.info("WakeWordConsumer stopped") diff --git a/extras/wakeword-service/data/reviewed_registry/.gitkeep b/extras/wakeword-service/data/reviewed_registry/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/extras/wakeword-service/detector.py b/extras/wakeword-service/detector.py new file mode 100644 index 00000000..77d605f1 --- /dev/null +++ b/extras/wakeword-service/detector.py @@ -0,0 +1,960 @@ +"""Acoustic wake-word detector + end-of-turn capture (multi-wake-word). + +Wraps, per client, N independent wake models plus a shared end-of-turn stack: + +- one ``NanoInterpreter`` per wake word (e.g. ``hey_hermes`` + ``hermes``), + scored in parallel on every frame — acoustic wake words. +- Silero VAD (``SileroOnnxModel``) — gates speech for end-of-turn. +- Smart Turn v3 (``LocalSmartTurnAnalyzerV3``) — semantic end-of-turn decision. + +Per-client arming state lives in :class:`ClientWakeState`, keyed by client_id +in the consumer. Audio frames arrive as int16 PCM at 16 kHz. + +Flow per client: + 1. Feed every frame to EACH wake interpreter. The first word to satisfy its + patience/threshold (in config-priority order) ARMS — a single arm per + debounce window across all words (a phrase like "hey hermes" that trips + several models dispatches once). Co-firing words are recorded as + ``also_fired`` metadata, never a second capture. + 2. While armed, run Silero VAD per 512-sample sub-frame and buffer it into the + Smart Turn analyzer. At each speech->silence pause, query the Smart Turn + MODEL (analyze_end_of_turn) for the semantic end-of-turn decision; on + COMPLETE the turn is captured -> emit. The analyzer's own stop_secs silence + timer is kept only as a LONG backstop if the model never fires. + 3. A max-arm-duration guard ends capture even if EOT never fires. + +Because the wake words can overlap acoustically (``hermes`` is a substring of +``hey hermes``), clean per-word POSITIVE data comes from the "prime + say it" +enrollment flow, where the user declares which word they are recording. Live +arms are attributed to the single arming word (the shorter word may occasionally +win a frame early for an overlapping phrase — that only affects which review +queue the trigger clip lands in, which a human reviews, not dispatch). +""" + +import asyncio +import logging +import os +import time +from collections import deque +from dataclasses import dataclass, field +from typing import Optional + +import numpy as np +from nanowakeword import NanoInterpreter + + +def _load_interp(path: str): + """Pick the wake backend by model file: ``.pt`` -> HuBERT+conv-attn (GPU), + else the stock nanowakeword ONNX interpreter. Both expose the same + ``load_model``/``predict``/``reset``/``models`` surface. The HuBERT backend + (and its heavy torch import) is loaded LAZILY — only when a ``.pt`` model is + configured — so the stock CPU image never needs torch.""" + if path.endswith(".pt"): + from hubert_detector import HubertInterpreter + + return HubertInterpreter.load_model(path) + return NanoInterpreter.load_model(path) + + +from pipecat.audio.turn.base_turn_analyzer import EndOfTurnState +from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams +from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 +from pipecat.audio.vad.silero import SileroOnnxModel +from verifier import HubertVerifier, WakeVerifier + +logger = logging.getLogger(__name__) + +SAMPLE_RATE = 16000 +VAD_FRAME_SAMPLES = 512 # Silero requires exactly 512 samples @ 16 kHz +# nanowakeword's streaming feature pipeline only scores correctly on exactly +# 1280-sample (80 ms) frames. Feeding the raw 0.25 s / 4000-sample Redis chunks +# directly yields a flat 0.0 score — the live frames MUST be reframed to 1280. +WAKE_FRAME_SAMPLES = 1280 + +# The wake model scores a 16-embedding-frame window = 80 ms stride × 15 + 760 ms +# embedding window = 1.96 s of audio (its receptive field). A captured clip must +# cover at least this to reproduce a detection; cold-streaming reproduction is +# also alignment-sensitive, so we keep a margin. +RECEPTIVE_FIELD_SECONDS = 1.96 +# Rolling pre-roll kept per client so that on an arm we can snapshot the audio +# that *caused* it (the wake-word window) for false-positive review — separate +# from the command turn that follows. Sized to 3 s (> the 1.96 s receptive field +# + lead-in) so the snapshot reproduces the detection standalone; the model +# activation itself is the LAST ~1.96 s of the clip (the arm fires at its end). +PREROLL_SECONDS = 3.0 +PREROLL_SAMPLES = int(PREROLL_SECONDS * SAMPLE_RATE) +# Lead-in pulled from the pre-roll when a primed positive capture starts, so the +# very start of the utterance isn't clipped at speech onset. +PRIME_LEADIN_SAMPLES = int(0.3 * SAMPLE_RATE) + + +@dataclass +class ClientWakeState: + """Per-client wake-word + capture state (in-memory, v1).""" + + armed: bool = False + # Which wake word armed the current capture (None when not armed). + armed_wakeword: Optional[str] = None + # Other wake words that also scored over threshold at the arm instant. + also_fired: list = field(default_factory=list) + arm_time: float = 0.0 + arm_score: float = 0.0 + # Single shared arm gate: one arm per debounce window across all DISPATCH words. + last_detection_time: float = 0.0 + # Separate debounce for collect-only (shadow) firings, so they neither spam + # the review queue nor touch the real-arm debounce of dispatch words. + last_collect_time: float = 0.0 + # Per-wake-word consecutive-frame-over-threshold counters (patience). + consec: dict = field(default_factory=dict) + # Smart Turn analyzer is per-client (it holds an audio buffer + thread). + turn_analyzer: Optional[LocalSmartTurnAnalyzerV3] = None + vad_model: Optional[SileroOnnxModel] = None + # One wake interpreter PER wake word. Each NanoInterpreter holds a STREAMING + # feature buffer (raw audio -> mel -> 96-d embeddings) spanning ~2 s of + # context, so they are per-client and never shared across streams. + interpreters: dict = field(default_factory=dict) + # Leftover PCM samples not yet aligned to a 512-sample VAD frame. + vad_remainder: np.ndarray = field( + default_factory=lambda: np.empty(0, dtype=np.int16) + ) + # Leftover PCM not yet aligned to a 1280-sample wake-interpreter frame + # (shared across wake words — the reframing is identical for all). + wake_remainder: np.ndarray = field( + default_factory=lambda: np.empty(0, dtype=np.int16) + ) + # Raw int16 PCM of the armed command turn (arm -> EOT), for batch ASR. + capture_chunks: list = field(default_factory=list) + # Count of VAD speech frames seen during this armed capture. Used to gate the + # backend's batch ASR: near-silent captures (a false arm with nothing spoken) + # make self-diarizing ASR hallucinate, so we flag them and skip transcription. + capture_speech_frames: int = 0 + # Semantic end-of-turn (Smart Turn) tracking within an armed capture: have we + # heard speech yet, how many consecutive silent VAD frames since, and the + # silence-frame count at which to next query the Smart Turn model. + eot_speech_seen: bool = False + eot_silence_frames: int = 0 + eot_next_check: int = 0 + # Rolling recent audio (samples) for snapshotting the wake-trigger window. + preroll: deque = field(default_factory=deque) + preroll_len: int = 0 + # Snapshot of the trigger window taken at arm time (for false-positive review). + trigger_audio: bytes = b"" + # Dev capture: interpreter buffer state snapshotted at arm (of the arming + # word's interpreter). The model's ~2 s receptive field exceeds the 3 s + # trigger_audio window's usable part, so these make an arm exactly + # reproducible offline: trigger_features = the (N, 96) embedding buffer the + # wake model scored on; trigger_context = the full ~10 s raw-audio buffer. + trigger_features: Optional[np.ndarray] = None + trigger_context: bytes = b"" + # --- "prime + say it" positive-capture mode (data collection) --- + priming: bool = False + # Which wake word the user declared they are enrolling for this prime. + prime_wakeword: Optional[str] = None + prime_start: float = 0.0 # monotonic; whole-session hard-cap reference + prime_stop_requested: bool = False # manual "end now" from the UI + prime_speech_started: bool = False + prime_chunks: list = field(default_factory=list) + prime_silence_run: int = 0 # consecutive silent VAD frames after speech began + + +@dataclass +class WakeEvent: + """Emitted when a turn is captured. + + Two kinds: + - ``command``: a real acoustic arm + captured command turn (the live wake + path). ``audio`` is the command; ``trigger_audio`` is the wake-word window + snapshotted at arm, saved for false-positive review. + - ``primed_positive``: a "prime + say it" data-collection capture. ``audio`` + is the spoken wake-word utterance; ``score`` is the model's max score over + it and ``is_false_negative`` is True when that fell below threshold. + + ``wakeword`` is the word this event belongs to (the arming word for commands, + the declared word for primes). ``also_fired`` lists other words over threshold + at arm (command kind only) — recorded for visibility, never cross-written. + """ + + client_id: str + session_id: str + wakeword: str + # Raw int16 PCM @16k of the captured turn (command, or primed wake utterance). + audio: bytes + arm_time: float + eot_time: float + score: float + reason: str # "smart_turn" | "max_duration" | "stream_end" | "primed" | ... + kind: str = "command" # "command" | "primed_positive" + also_fired: list = field(default_factory=list) + # Collect-only (shadow) firing: the model fired but the word is in collect-only + # mode — snapshot the trigger window for FP review, but do NOT dispatch to the + # plugin, play a tone, or capture a command turn. (command kind only.) + collect_only: bool = False + # Whether VAD heard enough speech in the captured turn to be worth batch-ASR. + # False for near-silent false arms — the backend skips transcription so the + # ASR can't hallucinate a phantom command. (command kind only.) + has_speech: bool = True + # Wake-trigger window captured at arm (command kind only), for FP review. + trigger_audio: bytes = b"" + # primed_positive only: did the live model under-score this true positive? + is_false_negative: bool = False + # Dev capture (command kind): interpreter buffer state at arm, so the arm is + # reproducible offline. buffer_features = (N, 96) embeddings the wake model + # scored on; buffer_context = full ~10 s raw-audio buffer (int16 PCM bytes). + buffer_features: Optional[np.ndarray] = None + buffer_context: bytes = b"" + + +class HermesDetector: + """Loads N wake models + builds per-client capture state.""" + + def __init__( + self, + models: dict[str, str], + threshold: float = 0.9, + patience: int = 5, + debounce_secs: float = 3.0, + vad_threshold: float = 0.5, + stop_secs: float = 2.0, + max_arm_secs: float = 15.0, + smart_turn_model_path: Optional[str] = None, + silero_vad_model_path: Optional[str] = None, + eot_min_silence_secs: float = 0.2, + eot_recheck_secs: float = 0.3, + prime_timeout_secs: float = 10.0, + prime_trail_silence_secs: float = 0.6, + prime_max_secs: float = 4.0, + prime_vad_threshold: float = 0.3, + min_command_speech_secs: float = 0.3, + verifiers: Optional[dict[str, str]] = None, + verifier_threshold: Optional[float] = None, + thresholds: Optional[dict[str, float]] = None, + patiences: Optional[dict[str, int]] = None, + collect_only: Optional[list[str]] = None, + verifiers_disabled: Optional[list[str]] = None, + disabled: Optional[list[str]] = None, + ): + """Initialize the detector. + + Args: + models: Ordered ``{wakeword: onnx_path}``. Insertion order is the + arming PRIORITY when several words fire on the same frame (put the + more specific / lower-FP word first). + threshold: Default acoustic detection threshold (per-word override via + ``thresholds``). Favor precision. + verifiers: Optional ``{wakeword: verifier_npz_path}`` second-stage + verifiers (``.npz`` from ``training/train_verifier.py``). When set + for a word, each of its arms is confirmed by the verifier before it + dispatches; arms it judges false are dropped. Missing word -> no + verifier (stage-1 only) for that word. + verifier_threshold: Optional override of every verifier's trained + operating-point threshold. + patience: Default consecutive-frames-over-threshold required before + firing — the main false-positive suppressor (per-word override via + ``patiences``). + thresholds: Optional per-word threshold overrides. + patiences: Optional per-word patience overrides. + debounce_secs: Suppress repeat arming within this window (shared + across all wake words — one arm per window). + vad_threshold: Silero speech-probability threshold. + stop_secs: LONG backstop — silence (s) that forces end-of-turn if the + Smart Turn model never fires. End-of-turn is normally decided by + the model at each pause, not by this timer. + max_arm_secs: Hard cap on capture duration after arming. + eot_min_silence_secs: Silence after speech before the first Smart Turn + model query (the endpoint gap). + eot_recheck_secs: How often to re-query the model while silence + continues after an INCOMPLETE verdict. + smart_turn_model_path: Optional override for the Smart Turn ONNX. If + None, the analyzer loads its own pipecat-bundled copy. + silero_vad_model_path: Optional override for the Silero VAD ONNX. If + None, the pipecat-bundled copy is resolved automatically. + """ + if not models: + raise ValueError("HermesDetector needs at least one wake model") + self.models = dict(models) + self.wakewords = list(self.models.keys()) # config order = priority + # Words in "collect-only" (shadow) mode fire to gather false-positive + # review data but never dispatch a command / play a tone / block a real + # wake word — used to farm FPs live for a not-yet-trusted word. + self.collect_only = set(collect_only or []) + # Words whose second-stage verifier is toggled OFF at runtime (from the + # Wake-Word Lab). The verifier stays LOADED — this only skips its check so + # arms dispatch on the stage-1 model alone. Mutable; flipped via the UI. + self.verifiers_disabled = set(verifiers_disabled or []) + # Words disabled at runtime. Disabled words do not dispatch and do not + # emit collect-only shadow events. + self.disabled = set(disabled or []) + self.threshold = threshold + self.patience = patience + self.thresholds = { + w: (thresholds or {}).get(w, threshold) for w in self.wakewords + } + self.patiences = {w: (patiences or {}).get(w, patience) for w in self.wakewords} + self.debounce_secs = debounce_secs + # Diagnostic: log any frame scoring above this floor (0 = off). + self.score_log_floor = float(os.getenv("WAKEWORD_SCORE_LOG", "0") or 0) + self.vad_threshold = vad_threshold + self.stop_secs = stop_secs + self.max_arm_secs = max_arm_secs + # Endpoint gap (in VAD frames) of silence after speech before the first + # Smart Turn query, then how often to re-query while silence continues. + self.eot_min_silence_frames = max( + 1, int(eot_min_silence_secs * SAMPLE_RATE / VAD_FRAME_SAMPLES) + ) + self.eot_recheck_frames = max( + 1, int(eot_recheck_secs * SAMPLE_RATE / VAD_FRAME_SAMPLES) + ) + self.prime_timeout_secs = prime_timeout_secs + self.prime_trail_silence_frames = max( + 1, int(prime_trail_silence_secs * SAMPLE_RATE / VAD_FRAME_SAMPLES) + ) + self.prime_max_samples = int(prime_max_secs * SAMPLE_RATE) + # While priming we drop the VAD gate well below the live threshold so the + # primed utterance is auto-caught even if spoken softly — the whole point + # of "I'll say it now" is that we already know speech is coming. + self.prime_vad_threshold = prime_vad_threshold + # Minimum cumulative VAD speech (in frames) a captured command must contain + # to be sent for batch ASR. Below this the turn is treated as a near-silent + # false arm and the backend skips transcription (avoids ASR hallucination). + self.min_command_speech_frames = max( + 1, int(min_command_speech_secs * SAMPLE_RATE / VAD_FRAME_SAMPLES) + ) + self.smart_turn_model_path = smart_turn_model_path + + # Interpreters are per-client (built in new_client_state) — the streaming + # feature buffer is stateful and must not be shared across streams. Load a + # throwaway probe per word here only to validate it and read its key. + self._wake_keys: dict[str, str] = {} + self._wake_in_names: dict[str, str] = {} + for wakeword, path in self.models.items(): + logger.info( + f"Loading wake model '{wakeword}': {path} " + f"(threshold={self.thresholds[wakeword]}, " + f"patience={self.patiences[wakeword]})" + ) + probe = _load_interp(path) + key = next(iter(probe.models.keys())) + self._wake_keys[wakeword] = key + # Cache the wake ONNX input name so the verifier can score raw feature + # windows directly (it picks the peak window the model armed on). + self._wake_in_names[wakeword] = probe.models[key].get_inputs()[0].name + del probe + logger.info( + f"Loaded {len(self.wakewords)} wake model(s): {', '.join(self.wakewords)} " + f"(per-client interpreters)" + ) + + # Optional second-stage verifiers (FP suppression), one per word. Loaded + # once, shared read-only across clients (pure-numpy scoring, no state). + self.verifiers: dict[str, WakeVerifier | HubertVerifier] = {} + for wakeword, vpath in (verifiers or {}).items(): + if wakeword not in self.models: + logger.warning(f"verifier for unknown word '{wakeword}' — ignored") + continue + if vpath and os.path.exists(vpath): + # HuBERT words (.pt) score a 768-d HuBERT arm-window embedding; + # nanowakeword words score the 96-d Google window. Pick the matching + # verifier (both pure-numpy, share the folded-logreg .npz schema). + if self.models[wakeword].endswith(".pt"): + self.verifiers[wakeword] = HubertVerifier( + vpath, threshold=verifier_threshold + ) + else: + self.verifiers[wakeword] = WakeVerifier( + vpath, threshold=verifier_threshold + ) + logger.info(f"verifier enabled for '{wakeword}' ({vpath})") + elif vpath: + logger.warning( + f"verifier_path '{vpath}' for '{wakeword}' not found — " + f"stage-1 only for that word" + ) + if not self.verifiers: + logger.info("No verifiers configured — running stage-1 (wake models) only") + + # Resolve the Silero VAD ONNX path once (explicit override or bundled). + if silero_vad_model_path: + self._vad_model_path = silero_vad_model_path + else: + import importlib.resources as ir + + self._vad_model_path = str( + ir.files("pipecat.audio.vad.data").joinpath("silero_vad.onnx") + ) + + def new_client_state(self) -> ClientWakeState: + """Build fresh per-client state: one interpreter per wake word + VAD + ST.""" + analyzer = LocalSmartTurnAnalyzerV3( + smart_turn_model_path=self.smart_turn_model_path, + params=SmartTurnParams(stop_secs=self.stop_secs), + ) + analyzer.set_sample_rate(SAMPLE_RATE) + return ClientWakeState( + interpreters={w: _load_interp(p) for w, p in self.models.items()}, + consec={w: 0 for w in self.wakewords}, + turn_analyzer=analyzer, + vad_model=SileroOnnxModel(self._vad_model_path, force_onnx_cpu=True), + ) + + async def process_frame( + self, state: ClientWakeState, client_id: str, session_id: str, pcm: bytes + ) -> Optional[WakeEvent]: + """Process one PCM frame for a client; returns a WakeEvent on capture. + + Args: + state: This client's wake state. + client_id: Client identifier. + session_id: Audio session id (== client_id in this pipeline). + pcm: Raw int16 PCM bytes (any length; 0.25 s = 8000 bytes typical). + + Returns: + A WakeEvent when a turn is captured, else None. + """ + audio = np.frombuffer(pcm, dtype=np.int16) + if audio.size == 0: + return None + + # Keep recent audio so an arm can snapshot the window that triggered it + # (and so a primed capture has a short lead-in before speech onset). + self._push_preroll(state, audio) + + # The wake/prime paths run synchronous ONNX inference, so hand them to a + # worker thread. ONNX Runtime releases the GIL during inference, so + # concurrent client streams score in parallel instead of serializing on the + # event loop. Safe to thread: each client has its own per-state interpreters + # (see new_client_state), and a client's process_frame is awaited + # sequentially, so no two threads ever touch one state's models at once. + + # "Prime + say it" data-collection mode takes precedence over arming. + if state.priming: + return await asyncio.to_thread( + self._run_prime, state, client_id, session_id, audio + ) + + if not state.armed: + # _run_wake arms dispatch words (event comes later from capture) and + # returns a shadow event immediately for collect-only words. + return await asyncio.to_thread( + self._run_wake, state, client_id, session_id, audio + ) + + # Armed: drive VAD + Smart Turn to capture the command turn. Left inline — + # it's async (awaits the Smart Turn model, which already yields), capture is + # a brief per-turn phase, and a to_thread per 32 ms VAD frame would cost more + # in handoff than it saves. + return await self._run_capture(state, client_id, session_id, audio) + + def _push_preroll(self, state: ClientWakeState, audio: np.ndarray) -> None: + """Append audio to the rolling pre-roll, trimming to PREROLL_SAMPLES.""" + state.preroll.append(audio) + state.preroll_len += audio.size + while state.preroll_len > PREROLL_SAMPLES and len(state.preroll) > 1: + dropped = state.preroll.popleft() + state.preroll_len -= dropped.size + + @staticmethod + def _preroll_tail(state: ClientWakeState, n_samples: int) -> np.ndarray: + """Return the last ``n_samples`` of buffered pre-roll audio.""" + if not state.preroll: + return np.empty(0, dtype=np.int16) + buf = np.concatenate(list(state.preroll)) + return buf[-n_samples:] if buf.size > n_samples else buf + + @staticmethod + def _prime_leadin(state: ClientWakeState, onset_offset: int) -> np.ndarray: + """Lead-in (PRIME_LEADIN_SAMPLES) ending exactly at the prime onset. + + The pre-roll ends at "now" (the end of the current buffer); the onset + sits ``onset_offset`` samples before that end. We take the lead-in from + *before* the onset rather than the most-recent 0.3 s — pulling the tail + would duplicate the slice we immediately re-append as capture frames, + which produced a "he-hey hermes" echo at the clip's start. + """ + if not state.preroll: + return np.empty(0, dtype=np.int16) + buf = np.concatenate(list(state.preroll)) + end = buf.size - onset_offset # index of the onset within the pre-roll + start = max(0, end - PRIME_LEADIN_SAMPLES) + return buf[start:end] + + def _run_wake( + self, state: ClientWakeState, client_id: str, session_id: str, audio: np.ndarray + ) -> Optional[WakeEvent]: + # Reframe to exactly 1280-sample frames (carry a remainder across calls); + # the interpreters score 0.0 on any other frame size. + buf = ( + np.concatenate([state.wake_remainder, audio]) + if state.wake_remainder.size + else audio + ) + n_full = (buf.size // WAKE_FRAME_SAMPLES) * WAKE_FRAME_SAMPLES + state.wake_remainder = buf[n_full:].copy() + + for i in range(0, n_full, WAKE_FRAME_SAMPLES): + frame = buf[i : i + WAKE_FRAME_SAMPLES] + now = time.monotonic() + + # Score EVERY wake word on this frame; update each patience counter. + scores: dict[str, float] = {} + for w in self.wakewords: + s = state.interpreters[w].predict(frame).get(self._wake_keys[w], 0.0) + scores[w] = s + if self.score_log_floor and s > self.score_log_floor: + logger.info(f"score[{w}] {s:.3f} '{client_id}'") + state.consec[w] = state.consec[w] + 1 if s > self.thresholds[w] else 0 + + # Words ready to fire this frame, split by mode. + ready = [w for w in self.wakewords if state.consec[w] >= self.patiences[w]] + enabled_ready = [w for w in ready if w not in self.disabled] + # Disabled words should not keep accumulating patience forever. + for w in ready: + if w in self.disabled: + state.consec[w] = 0 + collect_ready = [w for w in enabled_ready if w in self.collect_only] + dispatch_ready = [w for w in enabled_ready if w not in self.collect_only] + + # 1) Collect-only (shadow) firing: snapshot the trigger window for FP + # review, but DON'T enter the shared armed/capture state (so it never + # blocks a real dispatch word), don't dispatch, and don't run the + # verifier (we want to farm what the RAW model fires on). Independent + # debounce; only the firing word's interpreter resets (keeps dispatch + # words warm). + if collect_ready and (now - state.last_collect_time) > self.debounce_secs: + cand = collect_ready[0] + tf, tc = self._snapshot_buffers(state.interpreters[cand]) + also = [ + w + for w in self.wakewords + if w != cand and scores[w] > self.thresholds[w] + ] + state.last_collect_time = now + state.consec[cand] = 0 + state.interpreters[cand].reset() + trig = self._preroll_tail(state, PREROLL_SAMPLES).tobytes() + logger.info( + f"👁 SHADOW '{client_id}' word={cand} " + f"score={scores[cand]:.4f} (collect-only)" + ) + return WakeEvent( + client_id=client_id, + session_id=session_id, + wakeword=cand, + audio=b"", + arm_time=now, + eot_time=now, + score=scores[cand], + reason="shadow_arm", + kind="command", + collect_only=True, + also_fired=also, + has_speech=False, + trigger_audio=trig, + buffer_features=tf, + buffer_context=tc, + ) + + # 2) Real dispatch arming: one arm per debounce window across all + # dispatch words, highest-priority ready word that passes its verifier. + if ( + not dispatch_ready + or (now - state.last_detection_time) <= self.debounce_secs + ): + continue + + arm_word = None + trigger_features, trigger_context = None, b"" + for cand in dispatch_ready: + # Snapshot the interpreter's streaming buffer that produced this + # candidate arm BEFORE any reset clears it — needed both by the + # verifier and for false-positive review. + tf, tc = self._snapshot_buffers(state.interpreters[cand]) + verifier = self.verifiers.get(cand) + if verifier is not None and cand not in self.verifiers_disabled: + if isinstance(verifier, HubertVerifier): + # HuBERT words expose no Google feature buffer; the verifier + # scores the arm-window HuBERT embedding the interpreter just + # computed (the frame that tripped patience). + passed, vprob = verifier.verify_embedding( + state.interpreters[cand].arm_window_embedding() + ) + elif tf is not None: + passed, vprob = verifier.verify( + tf, + state.interpreters[cand].models[self._wake_keys[cand]], + self._wake_in_names[cand], + ) + else: # no buffer to judge — fail open (never block a wake) + passed, vprob = True, 1.0 + if not passed: + # Reject: clear THIS word's patience but keep streaming (no + # interpreter reset, no debounce) so a real wake right after + # is not delayed; let a lower-priority ready word still arm. + state.consec[cand] = 0 + logger.info( + f"🚫 verifier REJECTED '{cand}' arm '{client_id}' " + f"(wake={scores[cand]:.4f} verify={vprob:.3f} " + f"< {verifier.threshold:.2f})" + ) + continue + logger.info( + f"✅ verifier confirmed '{cand}' '{client_id}' " + f"(verify={vprob:.3f})" + ) + arm_word = cand + trigger_features, trigger_context = tf, tc + break + + if arm_word is None: + continue + + # Other words also over threshold at this instant (visibility only). + also_fired = [ + w + for w in self.wakewords + if w != arm_word and scores[w] > self.thresholds[w] + ] + state.armed = True + state.armed_wakeword = arm_word + state.also_fired = also_fired + state.arm_time = now + state.arm_score = scores[arm_word] + state.last_detection_time = now + for w in self.wakewords: + state.consec[w] = 0 + # Snapshot the wake-trigger window for false-positive review (the + # audio that *caused* this arm, distinct from the command turn). + state.trigger_audio = self._preroll_tail(state, PREROLL_SAMPLES).tobytes() + state.trigger_features = trigger_features + state.trigger_context = trigger_context + # Reset all interpreters + wake remainder so capture/next wake start clean. + for w in self.wakewords: + state.interpreters[w].reset() + state.wake_remainder = np.empty(0, dtype=np.int16) + logger.info( + f"🔔 ARMED '{client_id}' word={arm_word} score={scores[arm_word]:.4f}" + f"{f' also_fired={also_fired}' if also_fired else ''}" + ) + return + + def flush( + self, state: ClientWakeState, client_id: str, session_id: str + ) -> Optional[WakeEvent]: + """Finalize an in-progress capture when the stream ends while armed. + + Bounded recordings (e.g. browser push-to-record) end before Smart Turn + can detect end-of-turn, so stream-end is itself the end of the turn. + """ + if state.priming: + if state.prime_speech_started: + return self._finish_prime( + state, client_id, session_id, "primed_stream_end" + ) + self._reset_prime(state) + return None + if state.armed: + return self._finish_capture(state, client_id, session_id, "stream_end") + return None + + async def _run_capture( + self, state: ClientWakeState, client_id: str, session_id: str, audio: np.ndarray + ) -> Optional[WakeEvent]: + analyzer = state.turn_analyzer + vad = state.vad_model + + # Buffer the raw command audio (arm -> EOT) for batch ASR by the backend. + state.capture_chunks.append(audio) + + # Re-chunk into 512-sample VAD frames, carrying a remainder across calls. + buf = ( + np.concatenate([state.vad_remainder, audio]) + if state.vad_remainder.size + else audio + ) + n_full = (buf.size // VAD_FRAME_SAMPLES) * VAD_FRAME_SAMPLES + state.vad_remainder = buf[n_full:].copy() + + for i in range(0, n_full, VAD_FRAME_SAMPLES): + frame = buf[i : i + VAD_FRAME_SAMPLES] + conf = float( + np.asarray( + vad(frame.astype(np.float32) / 32768.0, SAMPLE_RATE) + ).flatten()[0] + ) + is_speech = conf >= self.vad_threshold + + # Buffer the frame in the analyzer. append_audio only returns COMPLETE + # via its own stop_secs silence counter — now a LONG backstop, not the + # primary signal (see WAKEWORD_STOP_SECS). + backstop = analyzer.append_audio(frame.tobytes(), is_speech) + if backstop == EndOfTurnState.COMPLETE: + return self._finish_capture(state, client_id, session_id, "stop_secs") + + if is_speech: + # Speech (re)started: reset the endpoint tracking; the next query + # happens once a fresh ``eot_min_silence_frames`` gap accrues. + state.eot_speech_seen = True + state.capture_speech_frames += 1 + state.eot_silence_frames = 0 + state.eot_next_check = self.eot_min_silence_frames + elif state.eot_speech_seen: + # Silence after speech: at the endpoint gap (then periodically while + # silence continues) ask the Smart Turn MODEL whether the turn is + # semantically complete. This is the real end-of-turn decision. + state.eot_silence_frames += 1 + if state.eot_silence_frames >= state.eot_next_check: + model_state, _ = await analyzer.analyze_end_of_turn() + if model_state == EndOfTurnState.COMPLETE: + return self._finish_capture( + state, client_id, session_id, "smart_turn" + ) + # INCOMPLETE: the model expects more speech — keep listening and + # re-query after another stretch of continued silence. + state.eot_next_check = ( + state.eot_silence_frames + self.eot_recheck_frames + ) + + # Hard cap on capture duration. + if (time.monotonic() - state.arm_time) > self.max_arm_secs: + return self._finish_capture(state, client_id, session_id, "max_duration") + + return None + + def _finish_capture( + self, state: ClientWakeState, client_id: str, session_id: str, reason: str + ) -> WakeEvent: + eot_time = time.monotonic() + captured = ( + np.concatenate(state.capture_chunks).tobytes() + if state.capture_chunks + else b"" + ) + # Silence gate: a captured turn with little/no VAD speech is a false arm + # (nothing actually spoken after the wake word). Flag it so the backend + # skips batch ASR — self-diarizing ASR hallucinates phantom commands on + # near-silent audio. + speech_secs = state.capture_speech_frames * VAD_FRAME_SAMPLES / SAMPLE_RATE + has_speech = state.capture_speech_frames >= self.min_command_speech_frames + event = WakeEvent( + client_id=client_id, + session_id=session_id, + wakeword=state.armed_wakeword or self.wakewords[0], + audio=captured, + arm_time=state.arm_time, + eot_time=eot_time, + score=state.arm_score, + reason=reason, + kind="command", + also_fired=list(state.also_fired), + has_speech=has_speech, + trigger_audio=state.trigger_audio, + buffer_features=state.trigger_features, + buffer_context=state.trigger_context, + ) + logger.info( + f"🛑 CAPTURED '{client_id}' word={event.wakeword} reason={reason} " + f"dur={eot_time - state.arm_time:.2f}s audio={len(captured)}B " + f"speech={speech_secs:.2f}s has_speech={has_speech}" + ) + # Reset state for the next wake word. + state.armed = False + state.armed_wakeword = None + state.also_fired = [] + state.arm_time = 0.0 + state.arm_score = 0.0 + state.trigger_audio = b"" + state.trigger_features = None + state.trigger_context = b"" + state.vad_remainder = np.empty(0, dtype=np.int16) + state.wake_remainder = np.empty(0, dtype=np.int16) + state.capture_chunks = [] + state.capture_speech_frames = 0 + state.eot_speech_seen = False + state.eot_silence_frames = 0 + state.eot_next_check = 0 + if state.turn_analyzer is not None: + state.turn_analyzer.clear() + return event + + # ------------------------------------------------------------------ # + # "Prime + say it" positive capture (false-negative / hard-positive collection) + # ------------------------------------------------------------------ # + + def start_priming( + self, state: ClientWakeState, client_id: str, wakeword: str + ) -> None: + """Arm a one-shot positive capture: the next utterance is ``wakeword``. + + Used by the data-collection UI ("I'll say the wake word now"). The next + VAD-detected utterance is captured as a labeled positive for ``wakeword`` + regardless of the model's score; the score (against that word's model) is + computed afterwards to flag false negatives. Because the user declares the + word, this enrollment path is unambiguous even when wake words overlap. + """ + if wakeword not in self.models: + raise ValueError(f"unknown wake word '{wakeword}' (have {self.wakewords})") + state.priming = True + state.prime_wakeword = wakeword + state.prime_start = time.monotonic() + state.prime_stop_requested = False + state.prime_speech_started = False + state.prime_chunks = [] + state.prime_silence_run = 0 + state.vad_remainder = np.empty(0, dtype=np.int16) + if state.turn_analyzer is not None: + state.turn_analyzer.clear() + logger.info( + f"🎯 PRIMED positive capture for '{client_id}' word={wakeword} " + f"— awaiting speech" + ) + + def stop_priming(self, state: ClientWakeState) -> None: + """Request a manual end of an in-progress prime (the UI 'stop' button). + + The stream task finalizes on its next frame, saving whatever was heard so + far (or a short pre-roll fallback) so the attempt always lands in review. + """ + if state.priming: + state.prime_stop_requested = True + + def _run_prime( + self, state: ClientWakeState, client_id: str, session_id: str, audio: np.ndarray + ) -> Optional[WakeEvent]: + # Manual "end now" from the UI: finalize immediately with whatever we have. + if state.prime_stop_requested: + return self._finish_prime( + state, client_id, session_id, "primed_manual_stop" + ) + + vad = state.vad_model + buf = ( + np.concatenate([state.vad_remainder, audio]) + if state.vad_remainder.size + else audio + ) + n_full = (buf.size // VAD_FRAME_SAMPLES) * VAD_FRAME_SAMPLES + state.vad_remainder = buf[n_full:].copy() + + for i in range(0, n_full, VAD_FRAME_SAMPLES): + frame = buf[i : i + VAD_FRAME_SAMPLES] + conf = float( + np.asarray( + vad(frame.astype(np.float32) / 32768.0, SAMPLE_RATE) + ).flatten()[0] + ) + # Lowered gate (prime_vad_threshold) — we know speech is coming, so + # auto-catch it even when spoken softly. + is_speech = conf >= self.prime_vad_threshold + + if not state.prime_speech_started: + if is_speech: + # Seed with a short lead-in so the onset isn't clipped. The + # lead-in is taken from BEFORE the onset (onset sits buf.size-i + # samples before the pre-roll's end) so we don't re-include the + # slice we're about to append as frames (the "he-hey" echo). + state.prime_speech_started = True + state.prime_chunks = [ + self._prime_leadin(state, buf.size - i), + frame, + ] + state.prime_silence_run = 0 + continue + + state.prime_chunks.append(frame) + if is_speech: + state.prime_silence_run = 0 + else: + state.prime_silence_run += 1 + if state.prime_silence_run >= self.prime_trail_silence_frames: + return self._finish_prime(state, client_id, session_id, "primed") + + if state.prime_speech_started: + captured = sum(c.size for c in state.prime_chunks) + if captured >= self.prime_max_samples: + return self._finish_prime( + state, client_id, session_id, "primed_max_duration" + ) + + # Whole-session hard cap: stop within prime_timeout_secs no matter what and + # ALWAYS finalize (never a silent drop) so the attempt lands in review — + # captured speech if we heard any, else a short pre-roll fallback. + if time.monotonic() - state.prime_start > self.prime_timeout_secs: + reason = ( + "primed_timeout" if state.prime_speech_started else "primed_no_speech" + ) + return self._finish_prime(state, client_id, session_id, reason) + return None + + def _finish_prime( + self, state: ClientWakeState, client_id: str, session_id: str, reason: str + ) -> WakeEvent: + wakeword = state.prime_wakeword or self.wakewords[0] + if state.prime_chunks: + captured = np.concatenate(state.prime_chunks) + else: + # Manual stop / timeout with no VAD-gated speech: fall back to the + # recent pre-roll so the attempt still surfaces for review instead of + # vanishing (the user explicitly asked for it to always show up). + captured = self._preroll_tail(state, PREROLL_SAMPLES) + interp = state.interpreters[wakeword] + score = self._score_buffer(interp, wakeword, captured) if captured.size else 0.0 + is_fn = score < self.thresholds[wakeword] + event = WakeEvent( + client_id=client_id, + session_id=session_id, + wakeword=wakeword, + audio=captured.tobytes(), + arm_time=0.0, + eot_time=time.monotonic(), + score=score, + reason=reason, + kind="primed_positive", + is_false_negative=is_fn, + ) + logger.info( + f"🎯 PRIMED POSITIVE '{client_id}' word={wakeword} score={score:.4f} " + f"{'(FALSE NEGATIVE)' if is_fn else '(would-have-fired)'} " + f"dur={captured.size / SAMPLE_RATE:.2f}s reason={reason}" + ) + self._reset_prime(state) + return event + + def _score_buffer(self, interp, wakeword: str, audio: np.ndarray) -> float: + """Max wake score over a buffer using this word's interpreter (reset + before/after so the live streaming state isn't polluted).""" + key = self._wake_keys[wakeword] + interp.reset() + best = 0.0 + n_full = (audio.size // WAKE_FRAME_SAMPLES) * WAKE_FRAME_SAMPLES + for i in range(0, n_full, WAKE_FRAME_SAMPLES): + s = interp.predict(audio[i : i + WAKE_FRAME_SAMPLES]).get(key, 0.0) + best = max(best, s) + interp.reset() + return float(best) + + @staticmethod + def _snapshot_buffers(interp) -> tuple: + """Copy the interpreter's streaming buffers at arm time so the arm is + reproducible offline. Returns (feature_buffer copy as (N, 96) float32, + raw-audio buffer as int16 PCM bytes). Never raises — capture must not + break detection.""" + try: + pre = interp.preprocessor + feats = np.asarray(pre.feature_buffer, dtype=np.float32).copy() + raw = np.asarray(pre.raw_data_buffer, dtype=np.int16).tobytes() + return feats, raw + except Exception as e: # noqa: BLE001 + logger.warning(f"buffer-state snapshot failed: {e}") + return None, b"" + + @staticmethod + def _reset_prime(state: ClientWakeState) -> None: + state.priming = False + state.prime_wakeword = None + state.prime_start = 0.0 + state.prime_stop_requested = False + state.prime_speech_started = False + state.prime_chunks = [] + state.prime_silence_run = 0 + state.vad_remainder = np.empty(0, dtype=np.int16) + if state.turn_analyzer is not None: + state.turn_analyzer.clear() diff --git a/extras/wakeword-service/docker-compose.yml b/extras/wakeword-service/docker-compose.yml new file mode 100644 index 00000000..2a48bbd3 --- /dev/null +++ b/extras/wakeword-service/docker-compose.yml @@ -0,0 +1,103 @@ +services: + wakeword-service: + build: + context: . + dockerfile: Dockerfile + image: ${CHRONICLE_REGISTRY:-}chronicle-wakeword:${CHRONICLE_TAG:-latest} + container_name: chronicle-wakeword-service + ports: + # Host 8771 (8770 is taken by the kittentts service); container stays 8770. + - "${WAKEWORD_PORT:-8771}:8770" + environment: + # Shares the backend's Redis (same chronicle-network). + - REDIS_URL=${REDIS_URL:-redis://redis:6379/0} + # Optional HF token (gated weights). HuBERT-base is cached at build time from + # the PyTorch CDN, so this is plumbing for future HF-hosted backends. + - HF_TOKEN=${HF_TOKEN:-} + # Wake words to run in parallel, as name:file pairs (file under models/). + # Order = arming priority (lower-FP phrase first). The wake-word NAME is + # decoupled from the model FILE so a retrained model is swapped without + # renaming its sample-data dir or plugin condition. + # hey_hermes -> hey_hermes_f.onnx (FP-hardened conformer, verifier on top) + # hermes -> hermes_hubert_convattn.pt (HuBERT+conv-attn backend, runs on GPU) + # The .pt model routes to the HuBERT backend (detector.py); the image carries + # CUDA torch + cached HuBERT-base, and the GPU reservation below is required. + - WAKEWORD_MODELS=${WAKEWORD_MODELS:-hey_hermes:hey_hermes_f.onnx,hermes:hermes_hubert_convattn.pt} + - SMART_TURN_MODEL_PATH=/app/models/smart-turn-v3.2-cpu.onnx + - SILERO_VAD_MODEL_PATH=/app/models/silero_vad.onnx + - WAKEWORD_THRESHOLD=${WAKEWORD_THRESHOLD:-0.9} + - WAKEWORD_PATIENCE=${WAKEWORD_PATIENCE:-2} + # Per-word operating-point overrides. "hermes" is a single short word with a + # Per-word operating-point overrides (name:value,...). Since the + # 2026-06-11 hermes_b retrain (real-data conformer + verifier), "hermes" + # runs at the shared 0.9/patience-2 point — no overrides needed. + - WAKEWORD_THRESHOLDS=${WAKEWORD_THRESHOLDS:-} + - WAKEWORD_PATIENCES=${WAKEWORD_PATIENCES:-} + # Collect-only (shadow) words fire to farm false-positive review data but + # never dispatch / play a tone / block a real word. "hermes" went LIVE once + # its second-stage verifier landed (models/hermes_verifier.npz rejects ~97% + # of its raw firings, taking it from ~14 FP/hr to <1/hr). Re-add a word here + # to farm it silently. Empty = all configured words dispatch. + # This is the INITIAL default only: the Wake-Word Lab can toggle collect-only + # per word at runtime, persisting the choice to data/collect_only.json (in the + # mounted ./data volume), which then takes precedence over this on restart. + - WAKEWORD_COLLECT_ONLY=${WAKEWORD_COLLECT_ONLY:-} + - WAKEWORD_SCORE_LOG=${WAKEWORD_SCORE_LOG:-0} + - WAKEWORD_DEBOUNCE_SECS=${WAKEWORD_DEBOUNCE_SECS:-3.0} + - WAKEWORD_VAD_THRESHOLD=${WAKEWORD_VAD_THRESHOLD:-0.5} + - WAKEWORD_STOP_SECS=${WAKEWORD_STOP_SECS:-2.0} + - WAKEWORD_MAX_ARM_SECS=${WAKEWORD_MAX_ARM_SECS:-15.0} + # Second-stage verifiers (FP suppression), auto-enabled per word when + # models/_verifier.npz exists (hey_hermes has one; hermes will once + # real clips are labeled). Override paths via WAKEWORD_VERIFIERS=name:path,... + - WAKEWORD_VERIFIERS=${WAKEWORD_VERIFIERS:-} + - WAKEWORD_VERIFIER_THRESHOLD=${WAKEWORD_VERIFIER_THRESHOLD:-} + - WAKEWORD_DATA_DIR=/app/data/samples + - WAKEWORD_PRIME_TIMEOUT_SECS=${WAKEWORD_PRIME_TIMEOUT_SECS:-12.0} + - WAKEWORD_PRIME_TRAIL_SILENCE_SECS=${WAKEWORD_PRIME_TRAIL_SILENCE_SECS:-0.6} + - WAKEWORD_PRIME_MAX_SECS=${WAKEWORD_PRIME_MAX_SECS:-4.0} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + # Mount models read-write during dev so a freshly trained hermes.onnx can be + # dropped in without rebuilding the image. Remove for an immutable image. + # ./data holds captured wake-word clips (the training flywheel) — persisted + # on the host so labeled positives/negatives survive rebuilds and feed + # training/ingest_samples.py directly. + volumes: + - ./models:/app/models + - ./data:/app/data + # Dev: bind-mount the service source over the image's baked-in copy so code + # edits apply on a plain restart (services.py does `up --force-recreate`) with + # NO image rebuild. Mirrors the `COPY *.py ./` in the Dockerfile; read-only + # since the service never writes its own source. Drop these for an immutable + # production image (then a code change requires a rebuild). + - ./app.py:/app/app.py:ro + - ./consumer.py:/app/consumer.py:ro + - ./detector.py:/app/detector.py:ro + - ./samples.py:/app/samples.py:ro + - ./verifier.py:/app/verifier.py:ro + - ./hubert_detector.py:/app/hubert_detector.py:ro + restart: unless-stopped + # The "hermes" word runs the HuBERT .pt model on GPU. docker compose v2 honors + # this reservation for `docker compose up` (no swarm needed). + deploy: + resources: + reservations: + devices: + # count: all (not "1"): podman-compose maps count:N to the CDI device + # nvidia.com/gpu=, but WSL's nvidia-ctk only generates the + # "nvidia.com/gpu=all" device (no per-index entries), so count:1 is + # unresolvable there. "all" resolves on both docker and podman/WSL. + - driver: nvidia + count: all + capabilities: [gpu] + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8770/health"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + +networks: + default: + name: chronicle-network + external: true diff --git a/extras/wakeword-service/init.py b/extras/wakeword-service/init.py new file mode 100644 index 00000000..a79bcad7 --- /dev/null +++ b/extras/wakeword-service/init.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Chronicle Hermes Wake-Word Service setup. + +Configures the standalone acoustic wake-word detector. Writes a `.env` from the +template (preserving existing values) and warns if the trained model is missing. +""" + +import argparse +import os +import shutil +import sys +from pathlib import Path + +from dotenv import set_key +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Prompt + +# Repo root for shared utilities. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) +from setup_utils import read_env_value # noqa: E402 + +console = Console() +HERE = Path(__file__).resolve().parent +ENV_PATH = HERE / ".env" +ENV_TEMPLATE = HERE / ".env.template" +MODEL_PATH = HERE / "models" / "hey_hermes.onnx" + + +def resolve_hf_token(arg_token: str | None) -> str | None: + """HF token, in priority order: --hf-token arg, backend .env, repo-root .env, + this service's own .env. + + Mirrors how the wizard sources shared secrets: ``backends/advanced/.env`` is the + canonical hub on a main machine; the repo-root ``.env`` is the per-node store for + backend-less cluster-join nodes. + """ + if arg_token: + return arg_token + repo_root = HERE.parent.parent + for path in ( + repo_root / "backends" / "advanced" / ".env", + repo_root / ".env", + ENV_PATH, + ): + value = read_env_value(str(path), "HF_TOKEN") + if value: + return value + return None + + +def configure(non_interactive: bool = False, hf_token: str | None = None) -> None: + """Create/update .env and report model status.""" + console.print( + Panel.fit( + "Hermes Acoustic Wake-Word Service", + subtitle="standalone detector on the live audio stream", + ) + ) + + if not ENV_PATH.exists(): + if ENV_TEMPLATE.exists(): + shutil.copy(ENV_TEMPLATE, ENV_PATH) + console.print(f"[green]Created {ENV_PATH} from template[/green]") + else: + ENV_PATH.touch() + + defaults = { + "REDIS_URL": "redis://redis:6379/0", + # Host 8770 is used by the tts/kittentts service; wakeword maps to host 8771. + "WAKEWORD_PORT": "8771", + "WAKEWORD_THRESHOLD": "0.9", + "WAKEWORD_PATIENCE": "2", + "WAKEWORD_DEBOUNCE_SECS": "3.0", + "WAKEWORD_VAD_THRESHOLD": "0.5", + "WAKEWORD_STOP_SECS": "2.0", + "WAKEWORD_MAX_ARM_SECS": "15.0", + "LOG_LEVEL": "INFO", + } + + for key, default in defaults.items(): + existing = read_env_value(str(ENV_PATH), key) + if non_interactive: + value = existing or default + else: + value = Prompt.ask(key, default=existing or default) + set_key(str(ENV_PATH), key, value, quote_mode="never") + + # HF token (optional): persisted so it's available if a wake-word backend pulls + # gated HuggingFace weights. The bundled HuBERT-base is cached at build time from + # the PyTorch CDN, so this isn't exercised today, but keeps the plumbing uniform. + resolved_token = resolve_hf_token(hf_token) + if resolved_token: + set_key(str(ENV_PATH), "HF_TOKEN", resolved_token, quote_mode="never") + + console.print(f"[green]Wrote configuration to {ENV_PATH}[/green]") + + if not MODEL_PATH.exists(): + console.print( + Panel.fit( + f"[yellow]Wake-word model not found at {MODEL_PATH}.[/yellow]\n" + "Train it first:\n" + " cd training && ./fetch_datasets.sh\n" + " .venv-train/bin/nanowakeword -c hermes_config.yaml\n" + " cp trained_models/hermes/model/hermes.onnx ../models/hermes.onnx\n" + "The service will refuse to start until hermes.onnx is present.", + title="Model required", + ) + ) + else: + console.print(f"[green]Wake-word model present: {MODEL_PATH}[/green]") + + console.print("\n[bold]Next:[/bold] start with ./start.sh or:") + console.print(" cd extras/wakeword-service && docker compose up --build -d") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Hermes wake-word service setup") + parser.add_argument( + "--non-interactive", + action="store_true", + help="Use defaults / existing values without prompting.", + ) + parser.add_argument( + "--hf-token", + help="Hugging Face token (avoids HF rate-limits / unlocks gated repos)", + ) + args = parser.parse_args() + configure( + non_interactive=args.non_interactive or not sys.stdin.isatty(), + hf_token=args.hf_token, + ) + + +if __name__ == "__main__": + main() diff --git a/extras/wakeword-service/pyproject.toml b/extras/wakeword-service/pyproject.toml new file mode 100644 index 00000000..dcd7f733 --- /dev/null +++ b/extras/wakeword-service/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "wakeword-service" +version = "0.1.0" +description = "Hermes acoustic wake-word detection service for Chronicle" +requires-python = ">=3.12,<3.13" +dependencies = [ + "fastapi>=0.115,<1", + "uvicorn[standard]>=0.30,<1", + "redis>=5.0,<6", + "numpy>=1.26,<3", + "onnxruntime>=1.20,<2", + # nanowakeword runtime (interpreter only — no train extras in the image) + "nanowakeword>=2.1,<3", + # pipecat provides Silero VAD + Smart Turn v3 (standalone analyzers). + # Pinned to the tested release (PyPI tops out at 0.0.x; bundles smart-turn-v3.2). + "pipecat-ai==0.0.108", + "soxr>=0.5,<2", +] + +# nanowakeword (==2.1.4) and pipecat-ai are pulled from PyPI — the repo's +# untracked/ copies are pristine at those tags, so PyPI matches. The exact +# Smart Turn v3.2 + Silero VAD ONNX files are vendored into models/ at build +# time (vendor_models.sh) so the runtime never depends on a PyPI re-bundle. + +[tool.black] +line-length = 100 + +[tool.isort] +profile = "black" +line_length = 100 diff --git a/extras/wakeword-service/samples.py b/extras/wakeword-service/samples.py new file mode 100644 index 00000000..839879a9 --- /dev/null +++ b/extras/wakeword-service/samples.py @@ -0,0 +1,543 @@ +"""On-disk store for wake-word data-collection clips — the training flywheel. + +Every acoustic arm snapshots its trigger window to ``pending/`` for review; a +reviewer labels each true-wake / not-wake, which moves it to ``positive/`` / +``negative/``. The "prime + say it" flow also lands in ``pending/`` (tagged +``false_negative`` when the live model under-scored the utterance), so the user +confirms wake / not-wake before it rolls into training. + +The store is **wake-word-scoped**: clips live under a per-wake-word directory so +two wake words running in parallel (e.g. ``hey_hermes`` + ``hermes``) never mix. +A clip is stored under exactly ONE wake word — the word it was enrolled for +(prime) or the word that armed (live capture). When a live arm co-fires several +models, only the arming word's queue gets the clip; the rest are recorded in its +``also_fired`` metadata, never cross-written. + +Layout (under WAKEWORD_DATA_DIR, default ``/app/data/samples``):: + + /pending/ .wav + .json awaiting review + /positive/ .wav + .json confirmed wake word + /negative/ .wav + .json confirmed NOT the wake word + +``positive/`` and ``negative/`` are exactly the dirs the training ingest +(``training/ingest_samples.py --wakeword ``) consumes, so a labeled clip is +immediately retrain-ready — no separate export step. + +A clip is a 16 kHz mono WAV plus a sidecar JSON of metadata. Labeling/deleting is +a file move/unlink, so the store has no central index to keep consistent. +""" + +import hashlib +import json +import logging +import os +import re +import shutil +import uuid +import wave +from collections import defaultdict +from typing import Optional + +import numpy as np + +logger = logging.getLogger(__name__) + +PENDING = "pending" +POSITIVE = "positive" +NEGATIVE = "negative" +BUCKETS = (PENDING, POSITIVE, NEGATIVE) + +# A label maps to the bucket a reviewed clip lands in. +_LABEL_BUCKET = {"wake": POSITIVE, "not_wake": NEGATIVE} + +_SANITIZE = re.compile(r"[^A-Za-z0-9_-]") + + +def _safe(name: str) -> str: + """Filesystem-safe wake-word directory name.""" + return _SANITIZE.sub("", name) or "unknown" + + +class SampleStore: + """File-backed store of wake-word clips, bucketed by wake word and review state.""" + + def __init__( + self, + base_dir: str, + wakewords: list[str], + legacy_wakeword: Optional[str] = None, + ): + """Create the store, ensuring per-wake-word bucket dirs exist. + + Args: + base_dir: Root directory for the wake-word sample tree. + wakewords: The wake words this deployment runs (one subtree each). + legacy_wakeword: If set and the OLD flat ``base_dir/`` layout + is found (pre-multi-wake-word), its clips are moved one-time into + ``base_dir//``. All historically-collected + clips are "hey hermes", so this keeps them from contaminating a + second wake word's data. + """ + self.base_dir = base_dir + self.wakewords = [_safe(w) for w in wakewords] + os.makedirs(base_dir, exist_ok=True) + if legacy_wakeword: + self._migrate_legacy_flat(_safe(legacy_wakeword)) + for wakeword in self.wakewords: + for bucket in BUCKETS: + os.makedirs(os.path.join(base_dir, wakeword, bucket), exist_ok=True) + logger.info( + f"SampleStore at {base_dir} " + f"(wakewords={', '.join(self.wakewords)}; buckets={', '.join(BUCKETS)})" + ) + + def _migrate_legacy_flat(self, target: str) -> None: + """One-time move of pre-multi-wake-word flat buckets into ``target``. + + The old store wrote ``base_dir/pending|positive|negative`` directly. If + those exist, relocate their contents under ``base_dir//`` + and remove the now-empty flat dirs. Idempotent — a no-op once migrated. + """ + for bucket in BUCKETS: + flat = os.path.join(self.base_dir, bucket) + if not os.path.isdir(flat): + continue + dest = os.path.join(self.base_dir, target, bucket) + os.makedirs(dest, exist_ok=True) + moved = 0 + for name in os.listdir(flat): + os.replace(os.path.join(flat, name), os.path.join(dest, name)) + moved += 1 + try: + os.rmdir(flat) + except OSError: + pass + if moved: + logger.info( + f"Migrated {moved} legacy '{bucket}' clips -> {target}/{bucket}" + ) + + def _iter_wakewords(self) -> list[str]: + """Wake-word subtrees currently on disk (configured ones + any extras).""" + found = set(self.wakewords) + try: + for name in os.listdir(self.base_dir): + if os.path.isdir(os.path.join(self.base_dir, name)): + found.add(name) + except OSError: + pass + return sorted(found) + + def _bucket_dir(self, wakeword: str, bucket: str) -> str: + if bucket not in BUCKETS: + raise ValueError(f"unknown bucket '{bucket}' (expected {BUCKETS})") + return os.path.join(self.base_dir, _safe(wakeword), bucket) + + @staticmethod + def _new_clip_id(client_id: str, created_at_ms: int) -> str: + client = _SANITIZE.sub("", client_id) or "unknown" + return f"{created_at_ms}_{client}_{uuid.uuid4().hex[:6]}" + + def _wav_path(self, wakeword: str, bucket: str, clip_id: str) -> str: + return os.path.join(self._bucket_dir(wakeword, bucket), f"{clip_id}.wav") + + def _json_path(self, wakeword: str, bucket: str, clip_id: str) -> str: + return os.path.join(self._bucket_dir(wakeword, bucket), f"{clip_id}.json") + + # Dev buffer-state sidecars (interpreter snapshot at arm). Optional — only + # present for clips captured with WAKEWORD_SAVE_BUFFER_STATE enabled. + def _features_path(self, wakeword: str, bucket: str, clip_id: str) -> str: + return os.path.join( + self._bucket_dir(wakeword, bucket), f"{clip_id}.features.npy" + ) + + def _context_path(self, wakeword: str, bucket: str, clip_id: str) -> str: + return os.path.join( + self._bucket_dir(wakeword, bucket), f"{clip_id}.context.wav" + ) + + def _sidecars(self, wakeword: str, bucket: str, clip_id: str) -> tuple: + """All optional auxiliary files for a clip (buffer-state sidecars).""" + return ( + self._features_path(wakeword, bucket, clip_id), + self._context_path(wakeword, bucket, clip_id), + ) + + @staticmethod + def _write_wav(path: str, pcm: bytes, sample_rate: int) -> None: + with wave.open(path, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(pcm) + + @staticmethod + def _audio_sha1(pcm: bytes) -> str: + """Content hash of the raw PCM — the dedup key (exact-duplicate clips).""" + return hashlib.sha1(pcm).hexdigest() + + @staticmethod + def _read_wav_frames(path: str) -> bytes: + with wave.open(path, "rb") as wf: + return wf.readframes(wf.getnframes()) + + def _clip_hash(self, wakeword: str, bucket: str, clip_id: str) -> Optional[str]: + """Content hash for a stored clip — from its JSON if present, else the WAV.""" + try: + with open(self._json_path(wakeword, bucket, clip_id)) as fh: + stored = json.load(fh).get("audio_sha1") + if stored: + return stored + except (OSError, json.JSONDecodeError): + pass + try: + return self._audio_sha1( + self._read_wav_frames(self._wav_path(wakeword, bucket, clip_id)) + ) + except (OSError, wave.Error): + return None + + def existing_hashes(self, wakeword: str) -> set: + """Set of content hashes already stored for ``wakeword`` (all buckets). + + Used by the offline farmer to skip clips it already has. Backfills from + the WAV for older clips that predate the stored ``audio_sha1``. + """ + out: set = set() + for bucket in BUCKETS: + for rec in self.list(wakeword, bucket): + h = rec.get("audio_sha1") or self._clip_hash( + wakeword, bucket, rec["id"] + ) + if h: + out.add(h) + return out + + def save( + self, + wakeword: str, + bucket: str, + pcm: bytes, + sample_rate: int, + created_at_ms: int, + meta: dict, + features: Optional[np.ndarray] = None, + context_pcm: Optional[bytes] = None, + ) -> dict: + """Write a clip (WAV + JSON sidecar) into ``wakeword/bucket``. + + Args: + wakeword: The wake word this clip belongs to. + bucket: One of ``pending`` / ``positive`` / ``negative``. + pcm: Raw int16 mono PCM. + sample_rate: Sample rate of ``pcm`` (Hz). + created_at_ms: Capture timestamp (epoch ms) — also seeds the clip id. + meta: Extra metadata (client_id, score, reason, source, also_fired...). + features: Optional ``(N, 96)`` interpreter embedding buffer at arm — + feeding its last 16 frames to the wake model reproduces the live + score exactly. Saved as ``.features.npy``. + context_pcm: Optional full ~10 s raw-audio buffer (int16 PCM) at arm, + the complete context behind the trigger. Saved as + ``.context.wav``. + + Returns: + The stored record dict (meta + id/wakeword/bucket/sample_rate/...). + """ + client_id = str(meta.get("client_id", "unknown")) + clip_id = self._new_clip_id(client_id, created_at_ms) + record = { + **meta, + "id": clip_id, + "wakeword": _safe(wakeword), + "bucket": bucket, + "sample_rate": sample_rate, + "created_at_ms": created_at_ms, + "duration_secs": round(len(pcm) / 2 / max(sample_rate, 1), 2), + "audio_sha1": self._audio_sha1(pcm), + } + self._write_wav(self._wav_path(wakeword, bucket, clip_id), pcm, sample_rate) + # Optional dev buffer-state sidecars (exact offline reproduction). + if features is not None: + np.save( + self._features_path(wakeword, bucket, clip_id), np.asarray(features) + ) + record["feature_frames"] = int(np.asarray(features).shape[0]) + record["has_buffer_state"] = True + if context_pcm: + self._write_wav( + self._context_path(wakeword, bucket, clip_id), context_pcm, sample_rate + ) + record["context_secs"] = round( + len(context_pcm) / 2 / max(sample_rate, 1), 2 + ) + with open(self._json_path(wakeword, bucket, clip_id), "w") as fh: + json.dump(record, fh) + return record + + def list(self, wakeword: str, bucket: str) -> list[dict]: + """Return all clip records in ``wakeword/bucket``, newest first.""" + d = self._bucket_dir(wakeword, bucket) + records: list[dict] = [] + if not os.path.isdir(d): + return records + for name in os.listdir(d): + if not name.endswith(".json"): + continue + try: + with open(os.path.join(d, name)) as fh: + records.append(json.load(fh)) + except (OSError, json.JSONDecodeError) as e: + logger.warning(f"skipping unreadable sample meta {name}: {e}") + records.sort(key=lambda r: r.get("created_at_ms", 0), reverse=True) + return records + + def _find(self, clip_id: str) -> Optional[tuple]: + """Return ``(wakeword, bucket)`` a clip currently lives in, or None.""" + for wakeword in self._iter_wakewords(): + for bucket in BUCKETS: + if os.path.exists(self._wav_path(wakeword, bucket, clip_id)): + return wakeword, bucket + return None + + def wav_path(self, clip_id: str) -> Optional[str]: + """Absolute path to a clip's WAV, wherever it currently lives.""" + loc = self._find(clip_id) + return self._wav_path(loc[0], loc[1], clip_id) if loc else None + + def label(self, clip_id: str, label: str) -> dict: + """Apply a review label, moving the clip into its target bucket. + + Args: + clip_id: Clip identifier. + label: ``wake`` (-> positive) or ``not_wake`` (-> negative). + + Returns: + The updated record dict. + """ + target = _LABEL_BUCKET.get(label) + if target is None: + raise ValueError( + f"unknown label '{label}' (expected {list(_LABEL_BUCKET)})" + ) + loc = self._find(clip_id) + if loc is None: + raise KeyError(clip_id) + wakeword, src = loc + + with open(self._json_path(wakeword, src, clip_id)) as fh: + record = json.load(fh) + record["bucket"] = target + record["label"] = label + + if src != target: + os.replace( + self._wav_path(wakeword, src, clip_id), + self._wav_path(wakeword, target, clip_id), + ) + os.replace( + self._json_path(wakeword, src, clip_id), + self._json_path(wakeword, target, clip_id), + ) + # Move buffer-state sidecars alongside, if present. + for src_aux, dst_aux in zip( + self._sidecars(wakeword, src, clip_id), + self._sidecars(wakeword, target, clip_id), + ): + if os.path.exists(src_aux): + os.replace(src_aux, dst_aux) + with open(self._json_path(wakeword, target, clip_id), "w") as fh: + json.dump(record, fh) + return record + + def move(self, clip_id: str, wakeword: str, bucket: str = PENDING) -> dict: + """Move a clip to a DIFFERENT wake word's bucket (default pending). + + For the overlap case: a live arm attributed to one word (e.g. ``hey_hermes`` + by priority) that is really an utterance of another (``hermes``). Moves the + WAV + JSON + sidecars, rewrites ``wakeword``/``bucket``, drops any stale + ``label`` so it re-enters review, and records ``moved_from``. The embedding + sidecar is model-agnostic (shared front-end), so it stays valid. + """ + if bucket not in BUCKETS: + raise ValueError(f"unknown bucket '{bucket}' (expected {BUCKETS})") + loc = self._find(clip_id) + if loc is None: + raise KeyError(clip_id) + src_word, src_bucket = loc + dst_word = _safe(wakeword) + if dst_word == src_word and bucket == src_bucket: + return json.load(open(self._json_path(src_word, src_bucket, clip_id))) + os.makedirs(self._bucket_dir(dst_word, bucket), exist_ok=True) + + with open(self._json_path(src_word, src_bucket, clip_id)) as fh: + record = json.load(fh) + record["wakeword"] = dst_word + record["bucket"] = bucket + record["moved_from"] = {"wakeword": src_word, "bucket": src_bucket} + record.pop("label", None) + + os.replace( + self._wav_path(src_word, src_bucket, clip_id), + self._wav_path(dst_word, bucket, clip_id), + ) + for src_aux, dst_aux in zip( + self._sidecars(src_word, src_bucket, clip_id), + self._sidecars(dst_word, bucket, clip_id), + ): + if os.path.exists(src_aux): + os.replace(src_aux, dst_aux) + # Write the updated JSON at the destination, remove the source JSON. + os.remove(self._json_path(src_word, src_bucket, clip_id)) + with open(self._json_path(dst_word, bucket, clip_id), "w") as fh: + json.dump(record, fh) + return record + + def copy(self, clip_id: str, wakeword: str, bucket: str = PENDING) -> dict: + """Copy a clip into ANOTHER wake word's bucket (source stays put). + + For the shared-false-positive case: one firing that tripped several wake + words is a hard negative for each of them. Move re-homes (positive belongs + to one word); copy fans out (the FP belongs to all that fired). The copy + gets a FRESH clip id so it coexists with the original (per-word dedup won't + touch cross-word copies). The embedding sidecar is model-agnostic, so it + copies over valid. + """ + if bucket not in BUCKETS: + raise ValueError(f"unknown bucket '{bucket}' (expected {BUCKETS})") + loc = self._find(clip_id) + if loc is None: + raise KeyError(clip_id) + src_word, src_bucket = loc + dst_word = _safe(wakeword) + os.makedirs(self._bucket_dir(dst_word, bucket), exist_ok=True) + + with open(self._json_path(src_word, src_bucket, clip_id)) as fh: + record = json.load(fh) + new_id = self._new_clip_id( + str(record.get("client_id", "unknown")), + int(record.get("created_at_ms", 0)), + ) + record["id"] = new_id + record["wakeword"] = dst_word + record["bucket"] = bucket + record["copied_from"] = { + "id": clip_id, + "wakeword": src_word, + "bucket": src_bucket, + } + record.pop("label", None) + + shutil.copy2( + self._wav_path(src_word, src_bucket, clip_id), + self._wav_path(dst_word, bucket, new_id), + ) + for src_aux, dst_aux in zip( + self._sidecars(src_word, src_bucket, clip_id), + self._sidecars(dst_word, bucket, new_id), + ): + if os.path.exists(src_aux): + shutil.copy2(src_aux, dst_aux) + with open(self._json_path(dst_word, bucket, new_id), "w") as fh: + json.dump(record, fh) + return record + + def delete(self, clip_id: str) -> bool: + """Remove a clip (WAV + JSON) wherever it lives. Returns True if found.""" + loc = self._find(clip_id) + if loc is None: + return False + wakeword, bucket = loc + for path in ( + self._wav_path(wakeword, bucket, clip_id), + self._json_path(wakeword, bucket, clip_id), + *self._sidecars(wakeword, bucket, clip_id), + ): + try: + os.remove(path) + except FileNotFoundError: + pass + return True + + def stats(self) -> dict: + """Per-wake-word clip counts (+ how many positives were rescued FNs). + + Returns a dict keyed by wake word:: + + {"hey_hermes": {"pending": 3, "positive": 86, "negative": 128, + "false_negatives": 4}, ...} + """ + out: dict[str, dict] = {} + for wakeword in self._iter_wakewords(): + counts = {} + for bucket in BUCKETS: + d = self._bucket_dir(wakeword, bucket) + # Count the per-clip .json sidecar, NOT .wav: buffer-state capture + # writes a second `.context.wav` per clip, which would otherwise + # double the count here vs. what list() (json-based) actually shows. + counts[bucket] = ( + sum(1 for n in os.listdir(d) if n.endswith(".json")) + if os.path.isdir(d) + else 0 + ) + counts["false_negatives"] = sum( + 1 for r in self.list(wakeword, POSITIVE) if r.get("false_negative") + ) + out[wakeword] = counts + return out + + # Keep priority when deduping: a labeled clip beats a pending one (don't throw + # away review effort); among equals, keep the oldest (first collected). + _DEDUP_RANK = {POSITIVE: 2, NEGATIVE: 2, PENDING: 0} + + def dedupe(self, wakeword: Optional[str] = None) -> dict: + """Remove exact-duplicate clips (same audio content) within each wake word. + + Groups every clip (pending + positive + negative) by its PCM content hash; + for each group of duplicates keeps a single representative — preferring an + already-labeled clip over a pending one (so review effort is never lost), + and the oldest among equals — and deletes the rest (including duplicate + pending clips). Returns a per-word summary. + + Args: + wakeword: Limit to one wake word, or None for all. + """ + words = [_safe(wakeword)] if wakeword else self._iter_wakewords() + out: dict = {} + for w in words: + groups: dict = defaultdict(list) # sha1 -> [(bucket, clip_id, created_at)] + for bucket in BUCKETS: + for rec in self.list(w, bucket): + h = rec.get("audio_sha1") or self._clip_hash(w, bucket, rec["id"]) + if h: + groups[h].append( + (bucket, rec["id"], rec.get("created_at_ms", 0)) + ) + + removed: list = [] + conflicts = 0 + for members in groups.values(): + if len(members) < 2: + continue + labeled = {b for b, _, _ in members if b in (POSITIVE, NEGATIVE)} + if len(labeled) > 1: + conflicts += 1 # same audio labeled both wake AND not_wake + # Keep highest-rank bucket, oldest first; delete the rest. + ordered = sorted(members, key=lambda m: (-self._DEDUP_RANK[m[0]], m[2])) + for bucket, clip_id, _ in ordered[1:]: + if self.delete(clip_id): + removed.append({"id": clip_id, "bucket": bucket}) + out[w] = { + "duplicate_groups": sum(1 for m in groups.values() if len(m) > 1), + "removed": len(removed), + "removed_by_bucket": { + b: sum(1 for r in removed if r["bucket"] == b) for b in BUCKETS + }, + "kept_unique": len(groups), + "conflicts": conflicts, + } + logger.info( + f"dedupe '{w}': removed {len(removed)} dup(s) " + f"({out[w]['removed_by_bucket']}), {len(groups)} unique remain" + + (f", {conflicts} LABEL CONFLICTS" if conflicts else "") + ) + return out diff --git a/extras/wakeword-service/tones/LICENSE.md b/extras/wakeword-service/tones/LICENSE.md new file mode 100644 index 00000000..bbd1b093 --- /dev/null +++ b/extras/wakeword-service/tones/LICENSE.md @@ -0,0 +1 @@ +[Home Assistant Voice Preview Edition Sounds](https://github.com/esphome/home-assistant-voice-pe/tree/dev/sounds) © 2024 by [Clayton Charles Tapp](https://www.cctaudio.com/) is licensed under [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/?ref=chooser-v1) diff --git a/extras/wakeword-service/tones/armed.wav b/extras/wakeword-service/tones/armed.wav new file mode 100644 index 00000000..ddea22df Binary files /dev/null and b/extras/wakeword-service/tones/armed.wav differ diff --git a/extras/wakeword-service/tones/done.wav b/extras/wakeword-service/tones/done.wav new file mode 100644 index 00000000..d76ec5b4 Binary files /dev/null and b/extras/wakeword-service/tones/done.wav differ diff --git a/extras/wakeword-service/uv.lock b/extras/wakeword-service/uv.lock new file mode 100644 index 00000000..2f2f5fcd --- /dev/null +++ b/extras/wakeword-service/uv.lock @@ -0,0 +1,1190 @@ +version = 1 +revision = 2 +requires-python = "==3.12.*" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload_time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload_time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload_time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload_time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload_time = "2026-06-01T19:41:02.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload_time = "2026-06-01T19:37:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload_time = "2026-06-01T19:37:50.014Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload_time = "2026-06-01T19:37:51.96Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload_time = "2026-06-01T19:37:53.839Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload_time = "2026-06-01T19:37:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload_time = "2026-06-01T19:37:57.931Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload_time = "2026-06-01T19:38:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload_time = "2026-06-01T19:38:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload_time = "2026-06-01T19:38:04.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload_time = "2026-06-01T19:38:06.713Z" }, + { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload_time = "2026-06-01T19:38:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload_time = "2026-06-01T19:38:11.111Z" }, + { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload_time = "2026-06-01T19:38:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload_time = "2026-06-01T19:38:15.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload_time = "2026-06-01T19:38:17.624Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload_time = "2026-06-01T19:38:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload_time = "2026-06-01T19:38:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload_time = "2026-06-01T19:38:24.027Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload_time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload_time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload_time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload_time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload_time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload_time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload_time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload_time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload_time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload_time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload_time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload_time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload_time = "2021-06-11T10:22:45.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload_time = "2021-06-11T10:22:42.561Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload_time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload_time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload_time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload_time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload_time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload_time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload_time = "2026-06-03T15:19:04.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload_time = "2026-06-03T15:19:02.959Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload_time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload_time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload_time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload_time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload_time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload_time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload_time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload_time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload_time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload_time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload_time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload_time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload_time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload_time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload_time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload_time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload_time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload_time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload_time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload_time = "2026-04-29T20:42:38.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload_time = "2026-04-29T20:42:36.842Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload_time = "2026-05-06T06:18:15.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload_time = "2026-05-06T06:17:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload_time = "2026-05-06T06:17:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload_time = "2026-05-06T06:17:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload_time = "2026-05-06T06:17:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload_time = "2026-05-06T06:18:08.026Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload_time = "2026-05-06T06:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload_time = "2026-05-06T06:18:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload_time = "2026-05-06T06:18:21.7Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload_time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload_time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload_time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload_time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload_time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload_time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload_time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload_time = "2026-05-25T22:17:15.155Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d8/748ea0a47f0fa15227fe682f7a80826b4b7c096e4818044b8f56d6cb66d6/huggingface_hub-1.18.0.tar.gz", hash = "sha256:f0c5ecd1ef8c6a60f86f61ee278f2c1570ba9e279c9f54de9094210723b3613b", size = 812699, upload_time = "2026-06-05T09:26:33.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/03/40a05316cb6616e5b7efd7773656441ab04b4b022c2199e79bb4622a92a3/huggingface_hub-1.18.0-py3-none-any.whl", hash = "sha256:729be4a976fb706dcc02d176bcda8a3f32bdf21a294e8f4b3dda6fbcbc9c1ab1", size = 684411, upload_time = "2026-06-05T09:26:31.48Z" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload_time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload_time = "2021-09-17T21:40:39.897Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload_time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload_time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload_time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload_time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload_time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload_time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload_time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload_time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload_time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload_time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload_time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload_time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload_time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload_time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload_time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload_time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload_time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload_time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload_time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload_time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload_time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload_time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload_time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload_time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload_time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload_time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload_time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload_time = "2026-03-31T18:28:57.147Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload_time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload_time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload_time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload_time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload_time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload_time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload_time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload_time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload_time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload_time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload_time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload_time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload_time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload_time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload_time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload_time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload_time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload_time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload_time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload_time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload_time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload_time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload_time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload_time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload_time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload_time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload_time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload_time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload_time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "nanowakeword" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "onnxruntime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/db/f051437fc07aad0486e54ceab70816dc94951b9fe0efb40fb2636eac09cd/nanowakeword-2.1.3.tar.gz", hash = "sha256:0a1b34101058139ec25dfe5a2069cc241c64601b3874a33c484fd1c64f4951de", size = 153404, upload_time = "2026-05-18T09:15:19.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/9f/0ea7306396e0b39e67ffa4f751ebb300e991038547f590c4721848cd0019/nanowakeword-2.1.3-py3-none-any.whl", hash = "sha256:30a1a9c8586b1a09c02078964cbcf1decd611a163b0abf921995c717e39ebbd3", size = 151470, upload_time = "2026-05-18T09:15:17.855Z" }, +] + +[[package]] +name = "nltk" +version = "3.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "joblib" }, + { name = "regex" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload_time = "2026-03-24T06:13:40.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload_time = "2026-03-24T06:13:38.47Z" }, +] + +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload_time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload_time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload_time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload_time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload_time = "2026-04-24T02:02:31.477Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload_time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload_time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload_time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload_time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload_time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload_time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload_time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload_time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload_time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload_time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload_time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload_time = "2026-05-18T23:34:25.876Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.23.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload_time = "2025-10-22T03:47:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload_time = "2025-10-22T03:46:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload_time = "2025-10-22T03:46:27.773Z" }, + { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload_time = "2025-10-22T03:47:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload_time = "2025-10-22T03:47:28.106Z" }, +] + +[[package]] +name = "openai" +version = "2.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload_time = "2026-06-03T22:39:40.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload_time = "2026-06-03T22:39:38.964Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload_time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload_time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload_time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload_time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload_time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload_time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload_time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload_time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload_time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload_time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload_time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload_time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload_time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload_time = "2026-04-01T14:43:39.421Z" }, +] + +[[package]] +name = "pipecat-ai" +version = "0.0.108" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "docstring-parser" }, + { name = "loguru" }, + { name = "markdown" }, + { name = "nltk" }, + { name = "numba" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "openai" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pyloudnorm" }, + { name = "resampy" }, + { name = "soxr" }, + { name = "transformers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/c6/121d4a6088051695eb382457a09fd8515cc6c37de23c359f9b16be18124f/pipecat_ai-0.0.108.tar.gz", hash = "sha256:d6707333c9e1f909d654b329d2d85288b693a36a4e31e820e522df94e3308bb8", size = 11132175, upload_time = "2026-03-28T04:49:49.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/06/6b3ada9d5b6a22e80ead0b1586c46e165418a7a1714f13bc235ee46529a9/pipecat_ai-0.0.108-py3-none-any.whl", hash = "sha256:5eb58ce2e685913b2b6c14763ac065d3bdc41ab77bb635beddf22caea8a49df2", size = 10761913, upload_time = "2026-03-28T04:49:46.946Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload_time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload_time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload_time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload_time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload_time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload_time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload_time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload_time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload_time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload_time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload_time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload_time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload_time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload_time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload_time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload_time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload_time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload_time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload_time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload_time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload_time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload_time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload_time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload_time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload_time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload_time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload_time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload_time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload_time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload_time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload_time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload_time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload_time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload_time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload_time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload_time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload_time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload_time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload_time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload_time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload_time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload_time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload_time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload_time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload_time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload_time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload_time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload_time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload_time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload_time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload_time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload_time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload_time = "2026-05-21T19:54:35.362Z" }, +] + +[[package]] +name = "pyloudnorm" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/00/f915eaa75326f4209941179c2b93ac477f2040e4aeff5bb21d16eb8058f9/pyloudnorm-0.2.0.tar.gz", hash = "sha256:8bf597658ea4e1975c275adf490f6deb5369ea409f2901f939915efa4b681b16", size = 14037, upload_time = "2026-01-04T11:43:35.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/b6/65a49a05614b2548edbba3aab118f2ebe7441dfd778accdcdce9f6567f20/pyloudnorm-0.2.0-py3-none-any.whl", hash = "sha256:9bb69afb904f59d007a7f9ba3d75d16fb8aeef35c44d6df822a9f192d69cf13f", size = 10879, upload_time = "2026-01-04T11:43:34.534Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload_time = "2026-05-14T17:55:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload_time = "2026-05-14T17:55:03.262Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload_time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload_time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload_time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload_time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload_time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload_time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload_time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload_time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload_time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload_time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload_time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload_time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload_time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "redis" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/cf/128b1b6d7086200c9f387bd4be9b2572a30b90745ef078bd8b235042dc9f/redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c", size = 4626200, upload_time = "2025-07-25T08:06:27.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/26/5c5fa0e83c3621db835cfc1f1d789b37e7fa99ed54423b5f519beb931aa7/redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97", size = 272833, upload_time = "2025-07-25T08:06:26.317Z" }, +] + +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload_time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload_time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload_time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload_time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload_time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload_time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload_time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload_time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload_time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload_time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload_time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload_time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload_time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload_time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload_time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload_time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload_time = "2026-05-09T23:13:02.426Z" }, +] + +[[package]] +name = "resampy" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/f1/34be702a69a5d272e844c98cee82351f880985cfbca0cc86378011078497/resampy-0.4.3.tar.gz", hash = "sha256:a0d1c28398f0e55994b739650afef4e3974115edbe96cd4bb81968425e916e47", size = 3080604, upload_time = "2024-03-05T20:36:08.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b9/3b00ac340a1aab3389ebcc52c779914a44aadf7b0cb7a3bf053195735607/resampy-0.4.3-py3-none-any.whl", hash = "sha256:ad2ed64516b140a122d96704e32bc0f92b23f45419e8b8f478e5a05f83edcebd", size = 3076529, upload_time = "2024-03-05T20:36:02.439Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload_time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload_time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "safetensors" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload_time = "2025-11-19T15:18:43.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload_time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload_time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload_time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload_time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload_time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload_time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload_time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload_time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload_time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload_time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload_time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload_time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload_time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload_time = "2025-11-19T15:18:44.427Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload_time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload_time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload_time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload_time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload_time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload_time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload_time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload_time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload_time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload_time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload_time = "2026-02-23T00:19:12.024Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload_time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload_time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload_time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soxr" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/7e/f4b461944662ad75036df65277d6130f9411002bfb79e9df7dff40a31db9/soxr-1.0.0.tar.gz", hash = "sha256:e07ee6c1d659bc6957034f4800c60cb8b98de798823e34d2a2bba1caa85a4509", size = 171415, upload_time = "2025-09-07T13:22:21.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/f92b81f1a151c13afb114f57799b86da9330bec844ea5a0d3fe6a8732678/soxr-1.0.0-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:abecf4e39017f3fadb5e051637c272ae5778d838e5c3926a35db36a53e3a607f", size = 205508, upload_time = "2025-09-07T13:22:01.252Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1d/c945fea9d83ea1f2be9d116b3674dbaef26ed090374a77c394b31e3b083b/soxr-1.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:e973d487ee46aa8023ca00a139db6e09af053a37a032fe22f9ff0cc2e19c94b4", size = 163568, upload_time = "2025-09-07T13:22:03.558Z" }, + { url = "https://files.pythonhosted.org/packages/b5/80/10640970998a1d2199bef6c4d92205f36968cddaf3e4d0e9fe35ddd405bd/soxr-1.0.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ce273cca101aff3d8c387db5a5a41001ba76ef1837883438d3c652507a9ccc", size = 204707, upload_time = "2025-09-07T13:22:05.125Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/2726603c13c2126cb8ded9e57381b7377f4f0df6ba4408e1af5ddbfdc3dd/soxr-1.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f2a69686f2856d37823bbb7b78c3d44904f311fe70ba49b893af11d6b6047b", size = 238032, upload_time = "2025-09-07T13:22:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/530252227f4d0721a5524a936336485dfb429bb206a66baf8e470384f4a2/soxr-1.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:2a3b77b115ae7c478eecdbd060ed4f61beda542dfb70639177ac263aceda42a2", size = 172070, upload_time = "2025-09-07T13:22:07.62Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload_time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload_time = "2026-05-31T01:07:50.09Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload_time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload_time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload_time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload_time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload_time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload_time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload_time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload_time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload_time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload_time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload_time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload_time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload_time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload_time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload_time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload_time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload_time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload_time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/b3/36c8ecf72e8925200671613332db156d84b99b3aee742a41c1938ebb0808/tqdm-4.68.1.tar.gz", hash = "sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5", size = 171236, upload_time = "2026-06-05T17:23:15.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/aa/218a0eb34de1f753c83e4d0d1c8e7c4cef27f20dcb8342e024f63a80dc86/tqdm-4.68.1-py3-none-any.whl", hash = "sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8", size = 78354, upload_time = "2026-06-05T17:23:13.654Z" }, +] + +[[package]] +name = "transformers" +version = "5.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/38/d5f978bd5091019e89aef29b9a831f5cd70f2598963a3ead8b9570cab592/transformers-5.10.2.tar.gz", hash = "sha256:f9a44b9c8ca9ab1156b467f574d832ea066284299c2fd0ed84641ccb592751fc", size = 8799687, upload_time = "2026-06-04T18:43:49.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/6f/e1564b0cc182afa05e219a8e09a8e770ffaab879b6b824b56c819bd221da/transformers-5.10.2-py3-none-any.whl", hash = "sha256:8a669db546f82c7c3618cb46ceb0f0afd89292bc70f319c058f8332ec63e268d", size = 11003830, upload_time = "2026-06-04T18:43:45.303Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload_time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload_time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload_time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload_time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload_time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload_time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload_time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload_time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload_time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload_time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload_time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload_time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload_time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload_time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload_time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "wakeword-service" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "nanowakeword" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "pipecat-ai" }, + { name = "redis" }, + { name = "soxr" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115,<1" }, + { name = "nanowakeword", specifier = ">=2.1,<3" }, + { name = "numpy", specifier = ">=1.26,<3" }, + { name = "onnxruntime", specifier = ">=1.20,<2" }, + { name = "pipecat-ai", specifier = "==0.0.108" }, + { name = "redis", specifier = ">=5.0,<6" }, + { name = "soxr", specifier = ">=0.5,<2" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload_time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload_time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload_time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload_time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload_time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload_time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload_time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload_time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload_time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload_time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload_time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload_time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload_time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload_time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload_time = "2026-05-18T04:30:22.23Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload_time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload_time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload_time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload_time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload_time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload_time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload_time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload_time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload_time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload_time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload_time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload_time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload_time = "2024-12-07T15:28:26.465Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload_time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload_time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload_time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload_time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload_time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload_time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload_time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload_time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload_time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload_time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload_time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload_time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload_time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload_time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload_time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload_time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload_time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload_time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload_time = "2026-05-19T21:31:03.909Z" }, +] diff --git a/extras/wakeword-service/verifier.py b/extras/wakeword-service/verifier.py new file mode 100644 index 00000000..98a51946 --- /dev/null +++ b/extras/wakeword-service/verifier.py @@ -0,0 +1,155 @@ +"""Second-stage wake-word verifier — confirms an acoustic arm before dispatch. + +The acoustic wake model fires on both true "hey hermes" and a class of acoustic +false positives that score *identically* (~0.99); a score threshold cannot tell +them apart. This small per-deployment classifier (trained by +``training/train_verifier.py`` on the reviewed clip corpus) scores the embedding +window that fired and rejects arms it judges false — an openWakeWord-style custom +verifier on the nanowakeword 96-d embeddings. + +Pure numpy at inference: it loads folded logistic-regression weights from a +``.npz`` the trainer produces (feature standardisation folded into ``w``/``b``), +so the detector needs no sklearn. The window/pool helpers live here so the +trainer and the detector share ONE definition and cannot drift. +""" + +import logging + +import numpy as np + +logger = logging.getLogger(__name__) + +# The wake model input is a (16, 96) window: 16 embedding frames of 96 dims. +WINDOW = 16 +EMBED_DIM = 96 + + +def pool_features(window: np.ndarray, kind: str) -> np.ndarray: + """Pool a (16, 96) window into a feature vector. Must match the trainer.""" + win = np.asarray(window, dtype=np.float32) + if kind == "mean": + return win.mean(axis=0) + if kind == "meanstd": + return np.concatenate([win.mean(axis=0), win.std(axis=0)]) + if kind == "flatten": + return win.reshape(-1) + raise ValueError(f"unknown pool kind '{kind}'") + + +def peak_window(feature_buffer: np.ndarray, wake_session, in_name: str): + """The highest-scoring contiguous 16-frame window of an embedding buffer. + + This is the window that arms (the wake model's peak), so it is what the + verifier judges. Buffers shorter than 16 frames are front-padded, mirroring + the model's left-padded input when fewer than 16 frames exist. + + Returns ``(peak_score, window (16,96))``. + """ + buf = np.asarray(feature_buffer, dtype=np.float32) + if buf.shape[0] < WINDOW: + if buf.shape[0]: + pad = np.repeat(buf[:1], WINDOW - buf.shape[0], axis=0) + else: + pad = np.zeros((WINDOW, EMBED_DIM), dtype=np.float32) + buf = np.concatenate([pad, buf], axis=0) + windows = np.stack([buf[i : i + WINDOW] for i in range(buf.shape[0] - WINDOW + 1)]) + scores = wake_session.run(None, {in_name: windows.astype(np.float32)})[0].reshape( + -1 + ) + j = int(np.argmax(scores)) + return float(scores[j]), windows[j] + + +class WakeVerifier: + """Loads a trained verifier and judges arm windows. ``score`` is P(true wake).""" + + def __init__(self, path: str, threshold: float | None = None): + d = np.load(path, allow_pickle=True) + self.w = d["w"].astype(np.float32) + self.b = float(d["b"]) + self.pool = str(d["pool"]) + self.window = int(d["window"]) if "window" in d else WINDOW + # An explicit env override wins; otherwise use the trained operating point. + self.threshold = ( + float(threshold) if threshold is not None else float(d["threshold"]) + ) + loo = float(d["loo_auc"]) if "loo_auc" in d else float("nan") + logger.info( + f"WakeVerifier loaded: {path} (pool={self.pool}, dim={self.w.shape[0]}, " + f"threshold={self.threshold:.3f}, LOO-AUC={loo:.4f})" + ) + + def score_window(self, window: np.ndarray) -> float: + """P(true wake) for a single (16, 96) window.""" + x = pool_features(window, self.pool) + return float(1.0 / (1.0 + np.exp(-(float(self.w @ x) + self.b)))) + + def verify(self, feature_buffer: np.ndarray, wake_session, in_name: str): + """Judge an arm from the interpreter's embedding buffer at arm time. + + Picks the peak (arming) window from the buffer and scores it. + Returns ``(passed, prob)`` — ``passed`` is True when the arm is a true wake. + Fails OPEN (passed=True) on any error so the verifier can never silently + swallow real detections. + """ + try: + _peak, win = peak_window(feature_buffer, wake_session, in_name) + prob = self.score_window(win) + return prob >= self.threshold, prob + except Exception as e: # noqa: BLE001 - never break detection + logger.warning(f"verifier error, passing arm through: {e}") + return True, 1.0 + + +def pool_hubert(emb_seq: np.ndarray, kind: str) -> np.ndarray: + """Pool a (T, 768) HuBERT embedding sequence into a feature vector. The arm + window the HuBERT model scores is the rolling 1.5 s buffer (~74 frames). Must + match ``training/train_hubert_verifier.py``.""" + e = np.asarray(emb_seq, dtype=np.float32) + if e.ndim == 1: # already pooled + return e + if kind == "mean": + return e.mean(axis=0) + if kind == "meanstd": + return np.concatenate([e.mean(axis=0), e.std(axis=0)]) + raise ValueError(f"unknown pool kind '{kind}'") + + +class HubertVerifier: + """Second-stage verifier for HuBERT wake words. Same folded-logreg .npz schema + as :class:`WakeVerifier`, but scores a pooled **768-d HuBERT embedding** of the + arm window (the rolling 1.5 s the model just fired on) instead of a 96-d Google + window — HuBERT words expose no nanowakeword feature buffer. Pure numpy.""" + + def __init__(self, path: str, threshold: float | None = None): + d = np.load(path, allow_pickle=True) + self.w = d["w"].astype(np.float32) + self.b = float(d["b"]) + self.pool = str(d["pool"]) + self.threshold = ( + float(threshold) if threshold is not None else float(d["threshold"]) + ) + loo = float(d["loo_auc"]) if "loo_auc" in d else float("nan") + logger.info( + f"HubertVerifier loaded: {path} (pool={self.pool}, dim={self.w.shape[0]}, " + f"threshold={self.threshold:.3f}, LOO-AUC={loo:.4f})" + ) + + def score_embedding(self, emb_seq: np.ndarray) -> float: + """P(true wake) for the arm-window HuBERT embedding (T, 768).""" + x = pool_hubert(emb_seq, self.pool) + return float(1.0 / (1.0 + np.exp(-(float(self.w @ x) + self.b)))) + + def verify_embedding(self, emb_seq): + """Judge an arm from the HuBERT interpreter's arm-window embedding. + + Returns ``(passed, prob)``. Fails OPEN on any error (or missing embedding) + so the verifier can never silently swallow real detections.""" + try: + if emb_seq is None: + return True, 1.0 + prob = self.score_embedding(emb_seq) + return prob >= self.threshold, prob + except Exception as e: # noqa: BLE001 - never break detection + logger.warning(f"hubert verifier error, passing arm through: {e}") + return True, 1.0 diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..8baf78e0 --- /dev/null +++ b/install.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -e + +REPO="https://github.com/SimpleOpenSoftware/chronicle.git" +DIR="chronicle" + +# Get latest release tag +TAG=$(curl -sL https://api.github.com/repos/SimpleOpenSoftware/chronicle/releases/latest | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4) + +if [ -z "$TAG" ]; then + echo "error: could not determine latest release" + exit 1 +fi + +echo "Installing Chronicle $TAG..." + +if [ -d "$DIR" ]; then + echo "error: directory '$DIR' already exists" + exit 1 +fi + +git clone --depth 1 --branch "$TAG" "$REPO" "$DIR" +cd "$DIR" + +# Install uv if missing +if ! command -v uv > /dev/null 2>&1; then + echo "Installing uv package manager..." + curl -LsSf https://astral.sh/uv/install.sh | sh + . "$HOME/.local/bin/env" 2>/dev/null || export PATH="$HOME/.local/bin:$PATH" +fi + +# Reconnect stdin for interactive wizard +exec < /dev/tty +./wizard.sh diff --git a/k8s-manifests/cross-namespace-rbac.yaml b/k8s-manifests/cross-namespace-rbac.yaml index 632cbf2f..687c778d 100644 --- a/k8s-manifests/cross-namespace-rbac.yaml +++ b/k8s-manifests/cross-namespace-rbac.yaml @@ -31,4 +31,4 @@ subjects: roleRef: kind: Role name: config-reader - apiGroup: rbac.authorization.k8s.io \ No newline at end of file + apiGroup: rbac.authorization.k8s.io diff --git a/plugins/button_control/__init__.py b/plugins/button_control/__init__.py new file mode 100644 index 00000000..0b5b45ff --- /dev/null +++ b/plugins/button_control/__init__.py @@ -0,0 +1,12 @@ +""" +Button Configuration plugin for Chronicle. + +Maps device button presses (single / double) to configurable system actions — +stop the current TTS playback, close the current conversation, star it, or call +another plugin. The mapping lives in config.yml so the same firmware button can +be re-purposed without a code change. +""" + +from .plugin import ButtonControlPlugin + +__all__ = ["ButtonControlPlugin"] diff --git a/plugins/button_control/config.yml b/plugins/button_control/config.yml new file mode 100644 index 00000000..56025908 --- /dev/null +++ b/plugins/button_control/config.yml @@ -0,0 +1,9 @@ +# Button Configuration — map device button presses to actions. +# Each key (single_press / double_press) maps to one typed action. Valid types: +# stop_playback | close_conversation | star_conversation | call_plugin +# call_plugin also needs: plugin_id, action, and optional data. +actions: + single_press: + type: stop_playback + double_press: + type: close_conversation diff --git a/plugins/test_button_actions/plugin.py b/plugins/button_control/plugin.py similarity index 52% rename from plugins/test_button_actions/plugin.py rename to plugins/button_control/plugin.py index af180e23..0f85ec46 100644 --- a/plugins/test_button_actions/plugin.py +++ b/plugins/button_control/plugin.py @@ -1,28 +1,41 @@ """ -Test Button Actions plugin — maps device button events to configurable actions. +Button Configuration plugin — maps device button events to configurable actions. -Single press: close conversation (triggers post-processing pipeline) -Double press: cross-plugin call (e.g., toggle study lights via Home Assistant) +Each button event (single / double press) is mapped, in config.yml, to one typed +action: -Actions are configured in config.yml with typed enums for safety. + - stop_playback : stop the TTS currently playing on the device (barge-in) + - close_conversation : end the current conversation (triggers post-processing) + - star_conversation : star/unstar the current conversation + - call_plugin : dispatch an action to another plugin (e.g. Home Assistant) + +Actions are validated against the ``ButtonActionType`` enum so a typo in config +fails loudly instead of silently doing nothing. """ import logging from typing import Any, Dict, List, Optional from advanced_omi_backend.plugins.base import BasePlugin, PluginContext, PluginResult -from advanced_omi_backend.plugins.events import ButtonActionType, ConversationCloseReason, PluginEvent +from advanced_omi_backend.plugins.events import ( + ButtonActionType, + ConversationCloseReason, + PluginEvent, +) logger = logging.getLogger(__name__) -class TestButtonActionsPlugin(BasePlugin): +class ButtonControlPlugin(BasePlugin): """Maps button press events to configurable system actions.""" SUPPORTED_ACCESS_LEVELS: List[str] = ["button"] - name = "Test Button Actions" - description = "Map OMI device button presses to actions (close conversation, toggle lights, etc.)" + name = "Button Configuration" + description = ( + "Map device button presses to actions (stop playback, close/star " + "conversation, call another plugin)." + ) def __init__(self, config: Dict[str, Any]): super().__init__(config) @@ -30,18 +43,17 @@ def __init__(self, config: Dict[str, Any]): async def initialize(self): if not self.enabled: - logger.info("Test Button Actions plugin is disabled, skipping initialization") + logger.info("Button Configuration plugin is disabled, skipping init") return logger.info( - f"Test Button Actions plugin initialized with actions: " - f"{list(self.actions.keys())}" + f"Button Configuration plugin initialized with actions: " + f"{ {k: v.get('type') for k, v in self.actions.items()} }" ) async def on_button_event(self, context: PluginContext) -> Optional[PluginResult]: - """Handle button events by dispatching configured actions.""" + """Handle a button event by dispatching its configured action.""" event = context.event - # Map plugin event to action config key if event == PluginEvent.BUTTON_SINGLE_PRESS: action_key = "single_press" elif event == PluginEvent.BUTTON_DOUBLE_PRESS: @@ -58,24 +70,43 @@ async def on_button_event(self, context: PluginContext) -> Optional[PluginResult try: action_type = ButtonActionType(action_config.get("type", "")) except ValueError: - logger.warning(f"Unknown action type: {action_config.get('type')}") + logger.warning(f"Unknown button action type: {action_config.get('type')}") return PluginResult( success=False, message=f"Unknown action type: {action_config.get('type')}", ) + if action_type == ButtonActionType.STOP_PLAYBACK: + return await self._handle_stop_playback(context) if action_type == ButtonActionType.CLOSE_CONVERSATION: - return await self._handle_close_conversation(context, action_config) - elif action_type == ButtonActionType.STAR_CONVERSATION: - return await self._handle_star_conversation(context, action_config) - elif action_type == ButtonActionType.CALL_PLUGIN: + return await self._handle_close_conversation(context) + if action_type == ButtonActionType.STAR_CONVERSATION: + return await self._handle_star_conversation(context) + if action_type == ButtonActionType.CALL_PLUGIN: return await self._handle_call_plugin(context, action_config) return None - async def _handle_close_conversation( - self, context: PluginContext, action_config: dict - ) -> PluginResult: + async def _handle_stop_playback(self, context: PluginContext) -> PluginResult: + """Stop the TTS currently playing on the device that sent the button event.""" + if not context.services: + logger.error("PluginServices not available in context") + return PluginResult(success=False, message="Services not available") + + client_id = context.data.get("client_id") + if not client_id: + logger.warning("No client_id in button event data, cannot stop playback") + return PluginResult(success=False, message="No device for this event") + + await context.services.stop_playback(client_id) + logger.info(f"Button press stopped playback on {client_id}") + return PluginResult( + success=True, + message="Playback stopped by button press", + should_continue=False, + ) + + async def _handle_close_conversation(self, context: PluginContext) -> PluginResult: """Close the current conversation via PluginServices.""" if not context.services: logger.error("PluginServices not available in context") @@ -83,7 +114,7 @@ async def _handle_close_conversation( session_id = context.data.get("session_id") if not session_id: - logger.warning("No session_id in button event data, cannot close conversation") + logger.warning("No session_id in button event data, cannot close") return PluginResult(success=False, message="No active session") success = await context.services.close_conversation( @@ -92,19 +123,16 @@ async def _handle_close_conversation( ) if success: - logger.info(f"Button press closed conversation for session {session_id[:12]}") + logger.info(f"Button press closed conversation for {session_id[:12]}") return PluginResult( success=True, message="Conversation closed by button press", should_continue=False, ) - else: - logger.warning(f"Failed to close conversation for session {session_id[:12]}") - return PluginResult(success=False, message="Failed to close conversation") + logger.warning(f"Failed to close conversation for {session_id[:12]}") + return PluginResult(success=False, message="Failed to close conversation") - async def _handle_star_conversation( - self, context: PluginContext, action_config: dict - ) -> PluginResult: + async def _handle_star_conversation(self, context: PluginContext) -> PluginResult: """Star/unstar the current conversation via PluginServices.""" if not context.services: logger.error("PluginServices not available in context") @@ -112,25 +140,23 @@ async def _handle_star_conversation( session_id = context.data.get("session_id") if not session_id: - logger.warning("No session_id in button event data, cannot star conversation") + logger.warning("No session_id in button event data, cannot star") return PluginResult(success=False, message="No active session") success = await context.services.star_conversation(session_id=session_id) if success: - logger.info(f"Button press toggled star for session {session_id[:12]}") + logger.info(f"Button press toggled star for {session_id[:12]}") return PluginResult( - success=True, - message="Conversation star toggled by button press", + success=True, message="Conversation star toggled by button press" ) - else: - logger.warning(f"Failed to toggle star for session {session_id[:12]}") - return PluginResult(success=False, message="Failed to toggle conversation star") + logger.warning(f"Failed to toggle star for {session_id[:12]}") + return PluginResult(success=False, message="Failed to toggle conversation star") async def _handle_call_plugin( self, context: PluginContext, action_config: dict ) -> PluginResult: - """Dispatch action to another plugin via PluginServices.""" + """Dispatch an action to another plugin via PluginServices.""" if not context.services: logger.error("PluginServices not available in context") return PluginResult(success=False, message="Services not available") @@ -140,7 +166,9 @@ async def _handle_call_plugin( data = action_config.get("data", {}) if not plugin_id or not action: - logger.warning(f"call_plugin action missing plugin_id or action: {action_config}") + logger.warning( + f"call_plugin action missing plugin_id or action: {action_config}" + ) return PluginResult( success=False, message="Invalid call_plugin configuration" ) @@ -154,5 +182,6 @@ async def _handle_call_plugin( if result: return result - - return PluginResult(success=False, message=f"No response from plugin '{plugin_id}'") + return PluginResult( + success=False, message=f"No response from plugin '{plugin_id}'" + ) diff --git a/plugins/email_summarizer/README.md b/plugins/email_summarizer/README.md index f1a21a52..474b298f 100644 --- a/plugins/email_summarizer/README.md +++ b/plugins/email_summarizer/README.md @@ -38,7 +38,8 @@ Chronicle uses a clean three-file separation for plugin configuration: - Event subscriptions - Trigger conditions -This separation keeps secrets secure and configuration organized. See [`plugin-configuration.md`](../../../Docs/plugin-configuration.md) for details. +This separation keeps secrets secure and configuration organized. See the +[plugin configuration guide](../../docs/backend/plugin-configuration.md) for details. ## Configuration diff --git a/plugins/email_summarizer/__init__.py b/plugins/email_summarizer/__init__.py index 525acd51..e1e5584d 100644 --- a/plugins/email_summarizer/__init__.py +++ b/plugins/email_summarizer/__init__.py @@ -6,4 +6,4 @@ from .plugin import EmailSummarizerPlugin -__all__ = ['EmailSummarizerPlugin'] +__all__ = ["EmailSummarizerPlugin"] diff --git a/plugins/email_summarizer/email_service.py b/plugins/email_summarizer/email_service.py index b51de0b5..b42cfa7a 100644 --- a/plugins/email_summarizer/email_service.py +++ b/plugins/email_summarizer/email_service.py @@ -7,13 +7,17 @@ - Gmail and other SMTP providers - Async implementation """ + import asyncio import logging +import os import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Any, Dict, Optional +from dotenv import load_dotenv + from advanced_omi_backend.utils.logging_utils import mask_dict logger = logging.getLogger(__name__) @@ -36,13 +40,13 @@ def __init__(self, config: Dict[str, Any]): - from_email: Sender email address - from_name: Sender display name (default: 'Chronicle AI') """ - self.host = config.get('smtp_host') - self.port = config.get('smtp_port', 587) - self.username = config.get('smtp_username') - self.password = config.get('smtp_password') - self.use_tls = config.get('smtp_use_tls', True) - self.from_email = config.get('from_email') - self.from_name = config.get('from_name', 'Chronicle AI') + self.host = config.get("smtp_host") + self.port = config.get("smtp_port", 587) + self.username = config.get("smtp_username") + self.password = config.get("smtp_password") + self.use_tls = config.get("smtp_use_tls", True) + self.from_email = config.get("from_email") + self.from_name = config.get("from_name", "Chronicle AI") # Validate required configuration if not all([self.host, self.username, self.password, self.from_email]): @@ -64,7 +68,7 @@ async def send_email( to_email: str, subject: str, body_text: str, - body_html: Optional[str] = None + body_html: Optional[str] = None, ) -> bool: """ Send email via SMTP with HTML/text support. @@ -80,18 +84,18 @@ async def send_email( """ try: # Create message container - msg = MIMEMultipart('alternative') - msg['Subject'] = subject - msg['From'] = f"{self.from_name} <{self.from_email}>" - msg['To'] = to_email + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{self.from_name} <{self.from_email}>" + msg["To"] = to_email # Attach plain text version - text_part = MIMEText(body_text, 'plain') + text_part = MIMEText(body_text, "plain") msg.attach(text_part) # Attach HTML version if provided if body_html: - html_part = MIMEText(body_html, 'html') + html_part = MIMEText(body_html, "html") msg.attach(html_part) # Send email asynchronously (run in thread pool to avoid blocking) @@ -143,7 +147,9 @@ async def test_connection(self) -> bool: """ try: await asyncio.to_thread(self._test_smtp_connection) - logger.info(f"✅ SMTP connection test successful: {self.username}@{self.host}") + logger.info( + f"✅ SMTP connection test successful: {self.username}@{self.host}" + ) return True except Exception as e: logger.error(f"SMTP connection test failed: {e}", exc_info=True) @@ -172,34 +178,40 @@ def _test_smtp_connection(self) -> None: smtp_server.quit() except smtplib.SMTPAuthenticationError as e: # Note: Error message from smtplib should not contain password, but be cautious - raise Exception(f"SMTP Authentication failed for {self.username}. Check credentials. For Gmail, use an App Password instead of your regular password. Error: {str(e)}") + raise Exception( + f"SMTP Authentication failed for {self.username}. Check credentials. For Gmail, use an App Password instead of your regular password. Error: {str(e)}" + ) except smtplib.SMTPConnectError as e: - raise Exception(f"Failed to connect to SMTP server {self.host}:{self.port}. Check host and port. Error: {str(e)}") + raise Exception( + f"Failed to connect to SMTP server {self.host}:{self.port}. Check host and port. Error: {str(e)}" + ) except smtplib.SMTPServerDisconnected as e: - raise Exception(f"SMTP server disconnected unexpectedly. Check TLS settings (port 587 needs TLS, port 465 needs SSL). Error: {str(e)}") + raise Exception( + f"SMTP server disconnected unexpectedly. Check TLS settings (port 587 needs TLS, port 465 needs SSL). Error: {str(e)}" + ) except TimeoutError as e: - raise Exception(f"Connection to {self.host}:{self.port} timed out. Check firewall/network settings. Error: {str(e)}") + raise Exception( + f"Connection to {self.host}:{self.port} timed out. Check firewall/network settings. Error: {str(e)}" + ) except Exception as e: - raise Exception(f"SMTP connection test failed: {type(e).__name__}: {str(e)}") + raise Exception( + f"SMTP connection test failed: {type(e).__name__}: {str(e)}" + ) # Test script for development/debugging async def main(): """Test the SMTP email service.""" - import os - - from dotenv import load_dotenv - load_dotenv() config = { - 'smtp_host': os.getenv('SMTP_HOST', 'smtp.gmail.com'), - 'smtp_port': int(os.getenv('SMTP_PORT', 587)), - 'smtp_username': os.getenv('SMTP_USERNAME'), - 'smtp_password': os.getenv('SMTP_PASSWORD'), - 'smtp_use_tls': os.getenv('SMTP_USE_TLS', 'true').lower() == 'true', - 'from_email': os.getenv('FROM_EMAIL', 'noreply@chronicle.ai'), - 'from_name': os.getenv('FROM_NAME', 'Chronicle AI'), + "smtp_host": os.getenv("SMTP_HOST", "smtp.gmail.com"), + "smtp_port": int(os.getenv("SMTP_PORT", 587)), + "smtp_username": os.getenv("SMTP_USERNAME"), + "smtp_password": os.getenv("SMTP_PASSWORD"), + "smtp_use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true", + "from_email": os.getenv("FROM_EMAIL", "noreply@chronicle.ai"), + "from_name": os.getenv("FROM_NAME", "Chronicle AI"), } try: @@ -214,14 +226,14 @@ async def main(): return # Send test email - test_email = config['smtp_username'] # Send to self + test_email = config["smtp_username"] # Send to self print(f"\nSending test email to {test_email}...") success = await service.send_email( to_email=test_email, subject="Chronicle Email Service Test", body_text="This is a test email from Chronicle Email Service.\n\nIf you received this, the email service is working correctly!", - body_html="

Chronicle Email Service Test

This is a test email from Chronicle Email Service.

If you received this, the email service is working correctly!

" + body_html="

Chronicle Email Service Test

This is a test email from Chronicle Email Service.

If you received this, the email service is working correctly!

", ) if success: @@ -233,5 +245,5 @@ async def main(): print(f"❌ Error: {e}") -if __name__ == '__main__': +if __name__ == "__main__": asyncio.run(main()) diff --git a/plugins/email_summarizer/plugin.py b/plugins/email_summarizer/plugin.py index d5012d53..f589c427 100644 --- a/plugins/email_summarizer/plugin.py +++ b/plugins/email_summarizer/plugin.py @@ -3,7 +3,9 @@ Automatically sends email summaries after memory extraction. """ + import logging +import time from datetime import datetime from typing import Any, Dict, List, Optional @@ -33,7 +35,7 @@ class EmailSummarizerPlugin(BasePlugin): 4. Sends email via SMTP """ - SUPPORTED_ACCESS_LEVELS: List[str] = ['conversation'] + SUPPORTED_ACCESS_LEVELS: List[str] = ["conversation"] name = "Email Summarizer" description = "Sends email summaries after memory extraction" @@ -47,14 +49,13 @@ def __init__(self, config: Dict[str, Any]): """ super().__init__(config) - self.subject_prefix = config.get('subject_prefix', 'Conversation Summary') - self.include_conversation_id = config.get('include_conversation_id', True) - self.include_duration = config.get('include_duration', True) + self.subject_prefix = config.get("subject_prefix", "Conversation Summary") + self.include_conversation_id = config.get("include_conversation_id", True) + self.include_duration = config.get("include_duration", True) # Email service will be initialized in initialize() self.email_service: Optional[SMTPEmailService] = None - def register_prompts(self, registry) -> None: """Register email summarizer prompts with the prompt registry.""" registry.register_default( @@ -91,13 +92,13 @@ async def initialize(self): # Initialize SMTP email service try: smtp_config = { - 'smtp_host': self.config.get('smtp_host'), - 'smtp_port': self.config.get('smtp_port', 587), - 'smtp_username': self.config.get('smtp_username'), - 'smtp_password': self.config.get('smtp_password'), - 'smtp_use_tls': self.config.get('smtp_use_tls', True), - 'from_email': self.config.get('from_email'), - 'from_name': self.config.get('from_name', 'Chronicle AI'), + "smtp_host": self.config.get("smtp_host"), + "smtp_port": self.config.get("smtp_port", 587), + "smtp_username": self.config.get("smtp_username"), + "smtp_password": self.config.get("smtp_password"), + "smtp_use_tls": self.config.get("smtp_use_tls", True), + "from_email": self.config.get("from_email"), + "from_name": self.config.get("from_name", "Chronicle AI"), } self.email_service = SMTPEmailService(smtp_config) @@ -117,8 +118,6 @@ async def initialize(self): async def health_check(self) -> dict: """Test SMTP connectivity using the initialized email service.""" - import time - if not self.email_service: return {"ok": False, "message": "Email service not initialized"} @@ -127,8 +126,16 @@ async def health_check(self) -> dict: success = await self.email_service.test_connection() latency_ms = int((time.time() - start) * 1000) if success: - return {"ok": True, "message": "SMTP connected", "latency_ms": latency_ms} - return {"ok": False, "message": "SMTP connection failed", "latency_ms": latency_ms} + return { + "ok": True, + "message": "SMTP connected", + "latency_ms": latency_ms, + } + return { + "ok": False, + "message": "SMTP connection failed", + "latency_ms": latency_ms, + } except Exception as e: return {"ok": False, "message": str(e)} @@ -136,7 +143,9 @@ async def cleanup(self): """Clean up plugin resources.""" logger.info("Email Summarizer plugin cleanup complete") - async def on_conversation_complete(self, context: PluginContext) -> Optional[PluginResult]: + async def on_conversation_complete( + self, context: PluginContext + ) -> Optional[PluginResult]: """ Send email summary after all conversation processing completes. @@ -152,7 +161,7 @@ async def on_conversation_complete(self, context: PluginContext) -> Optional[Plu - duration: float """ try: - conversation_id = context.data.get('conversation_id', 'unknown') + conversation_id = context.data.get("conversation_id", "unknown") logger.info( f"Processing conversation.complete event for user: {context.user_id}, " f"conversation: {conversation_id}" @@ -163,25 +172,33 @@ async def on_conversation_complete(self, context: PluginContext) -> Optional[Plu Conversation.conversation_id == conversation_id ) if not conversation: - logger.warning(f"Conversation {conversation_id} not found in DB, skipping email") + logger.warning( + f"Conversation {conversation_id} not found in DB, skipping email" + ) return PluginResult(success=False, message="Conversation not found") # Get transcript from active version (computed property handles version lookup) transcript = conversation.transcript if not transcript: - logger.warning(f"No transcript for conversation {conversation_id}, skipping email") + logger.warning( + f"No transcript for conversation {conversation_id}, skipping email" + ) return PluginResult(success=False, message="Skipped: Empty transcript") # Use the DB summary (already generated by this point) summary = conversation.detailed_summary or conversation.summary if not summary: - logger.warning(f"No summary for conversation {conversation_id}, skipping email") - return PluginResult(success=False, message="Skipped: No summary available") + logger.warning( + f"No summary for conversation {conversation_id}, skipping email" + ) + return PluginResult( + success=False, message="Skipped: No summary available" + ) title = conversation.title or self.subject_prefix # Send to the configured SMTP username (the user's own email) - user_email = self.config.get('smtp_username') + user_email = self.config.get("smtp_username") if not user_email: return PluginResult( success=False, @@ -216,22 +233,28 @@ async def on_conversation_complete(self, context: PluginContext) -> Optional[Plu ) if success: - logger.info(f"✅ Email summary sent to {user_email} for conversation {conversation_id}") + logger.info( + f"✅ Email summary sent to {user_email} for conversation {conversation_id}" + ) return PluginResult( success=True, message=f"Email sent to {user_email}", data={ - 'recipient': user_email, - 'conversation_id': conversation_id, - 'title': title, + "recipient": user_email, + "conversation_id": conversation_id, + "title": title, }, ) else: logger.error(f"Failed to send email to {user_email}") - return PluginResult(success=False, message=f"Failed to send email to {user_email}") + return PluginResult( + success=False, message=f"Failed to send email to {user_email}" + ) except Exception as e: - logger.error(f"Error in email summarizer (conversation.complete): {e}", exc_info=True) + logger.error( + f"Error in email summarizer (conversation.complete): {e}", exc_info=True + ) return PluginResult(success=False, message=f"Error: {str(e)}") def _format_subject(self, created_at: Optional[datetime] = None) -> str: @@ -277,29 +300,34 @@ async def test_connection(config: Dict[str, Any]) -> Dict[str, Any]: >>> result['success'] True """ - import time - try: # Validate required config fields - required_fields = ['smtp_host', 'smtp_username', 'smtp_password', 'from_email'] - missing_fields = [field for field in required_fields if not config.get(field)] + required_fields = [ + "smtp_host", + "smtp_username", + "smtp_password", + "from_email", + ] + missing_fields = [ + field for field in required_fields if not config.get(field) + ] if missing_fields: return { "success": False, "message": f"Missing required fields: {', '.join(missing_fields)}", - "status": "error" + "status": "error", } # Build SMTP config smtp_config = { - 'smtp_host': config.get('smtp_host'), - 'smtp_port': config.get('smtp_port', 587), - 'smtp_username': config.get('smtp_username'), - 'smtp_password': config.get('smtp_password'), - 'smtp_use_tls': config.get('smtp_use_tls', True), - 'from_email': config.get('from_email'), - 'from_name': config.get('from_name', 'Chronicle AI'), + "smtp_host": config.get("smtp_host"), + "smtp_port": config.get("smtp_port", 587), + "smtp_username": config.get("smtp_username"), + "smtp_password": config.get("smtp_password"), + "smtp_use_tls": config.get("smtp_use_tls", True), + "from_email": config.get("from_email"), + "from_name": config.get("from_name", "Chronicle AI"), } # Log config with masked secrets for debugging @@ -321,27 +349,29 @@ async def test_connection(config: Dict[str, Any]) -> Dict[str, Any]: "message": f"Successfully connected to SMTP server at {smtp_config['smtp_host']}", "status": "success", "details": { - "smtp_host": smtp_config['smtp_host'], - "smtp_port": smtp_config['smtp_port'], + "smtp_host": smtp_config["smtp_host"], + "smtp_port": smtp_config["smtp_port"], "connection_time_ms": connection_time_ms, - "use_tls": smtp_config['smtp_use_tls'] - } + "use_tls": smtp_config["smtp_use_tls"], + }, } else: return { "success": False, "message": "SMTP connection test failed", - "status": "error" + "status": "error", } except Exception as e: logger.error(f"SMTP connection test failed: {e}", exc_info=True) error_msg = str(e) - + # Provide helpful hints based on error type hints = [] if "Authentication" in error_msg or "535" in error_msg: - hints.append("For Gmail: Enable 2FA and create an App Password at https://myaccount.google.com/apppasswords") + hints.append( + "For Gmail: Enable 2FA and create an App Password at https://myaccount.google.com/apppasswords" + ) hints.append("Verify your username and password are correct") elif "Connection" in error_msg or "timeout" in error_msg.lower(): hints.append("Check your SMTP host and port settings") @@ -349,10 +379,10 @@ async def test_connection(config: Dict[str, Any]) -> Dict[str, Any]: elif "TLS" in error_msg or "SSL" in error_msg: hints.append("For port 587: Enable TLS") hints.append("For port 465: Disable TLS (uses implicit SSL)") - + return { "success": False, "message": f"Connection test failed: {error_msg}", "status": "error", - "hints": hints + "hints": hints, } diff --git a/plugins/email_summarizer/setup.py b/plugins/email_summarizer/setup.py index 518613ff..21cd8632 100755 --- a/plugins/email_summarizer/setup.py +++ b/plugins/email_summarizer/setup.py @@ -14,10 +14,10 @@ from datetime import datetime from pathlib import Path -from ruamel.yaml import YAML from dotenv import set_key from rich.console import Console from rich.prompt import Confirm +from ruamel.yaml import YAML # Add repo root to path for setup_utils import project_root = Path(__file__).resolve().parents[6] @@ -41,42 +41,44 @@ def update_plugins_yml_orchestration(): # Load existing or create from template if plugins_yml_path.exists(): - with open(plugins_yml_path, 'r') as f: + with open(plugins_yml_path, "r") as f: config = _yaml.load(f) or {} else: # Copy from template template_path = project_root / "config" / "plugins.yml.template" if template_path.exists(): - with open(template_path, 'r') as f: + with open(template_path, "r") as f: config = _yaml.load(f) or {} else: - config = {'plugins': {}} + config = {"plugins": {}} # Ensure structure exists - if 'plugins' not in config: - config['plugins'] = {} + if "plugins" not in config: + config["plugins"] = {} # Only orchestration settings in config/plugins.yml # Plugin-specific settings are in plugins/email_summarizer/config.yml plugin_config = { - 'enabled': False, # Let user enable manually or prompt - 'events': ['conversation.complete'], - 'condition': {'type': 'always'} + "enabled": False, # Let user enable manually or prompt + "events": ["conversation.complete"], + "condition": {"type": "always"}, } # Update or create plugin entry - config['plugins']['email_summarizer'] = plugin_config + config["plugins"]["email_summarizer"] = plugin_config # Backup existing file if plugins_yml_path.exists(): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_path = plugins_yml_path.parent / f"plugins.yml.backup.{timestamp}" shutil.copy(plugins_yml_path, backup_path) - console.print(f"[dim]Backed up existing plugins.yml to {backup_path.name}[/dim]") + console.print( + f"[dim]Backed up existing plugins.yml to {backup_path.name}[/dim]" + ) # Write updated config plugins_yml_path.parent.mkdir(parents=True, exist_ok=True) - with open(plugins_yml_path, 'w') as f: + with open(plugins_yml_path, "w") as f: _yaml.dump(config, f) console.print("[green]✅ Updated config/plugins.yml (orchestration only)[/green]") @@ -94,15 +96,17 @@ def main(): # SMTP Configuration console.print("[bold]SMTP Configuration[/bold]") - console.print("[dim]For Gmail: Use App Password (Settings > Security > 2FA > App Passwords)[/dim]\n") + console.print( + "[dim]For Gmail: Use App Password (Settings > Security > 2FA > App Passwords)[/dim]\n" + ) smtp_host = prompt_with_existing_masked( prompt_text="SMTP Host", env_file_path=env_path, env_key="SMTP_HOST", - placeholders=['your-smtp-host-here'], + placeholders=["your-smtp-host-here"], is_password=False, - default="smtp.gmail.com" + default="smtp.gmail.com", ) smtp_port = prompt_value("SMTP Port", default="587") @@ -111,16 +115,16 @@ def main(): prompt_text="SMTP Username (your email)", env_file_path=env_path, env_key="SMTP_USERNAME", - placeholders=['your-email@example.com'], - is_password=False + placeholders=["your-email@example.com"], + is_password=False, ) smtp_password = prompt_with_existing_masked( prompt_text="SMTP Password (App Password)", env_file_path=env_path, env_key="SMTP_PASSWORD", - placeholders=['your-password-here', 'your-app-password-here'], - is_password=True # Shows masked existing value + placeholders=["your-password-here", "your-app-password-here"], + is_password=True, # Shows masked existing value ) # Remove spaces from app password (Google adds spaces when copying) @@ -133,9 +137,9 @@ def main(): prompt_text="From Email", env_file_path=env_path, env_key="FROM_EMAIL", - placeholders=['noreply@example.com'], + placeholders=["noreply@example.com"], is_password=False, - default=smtp_username # Default to SMTP username + default=smtp_username, # Default to SMTP username ) from_name = prompt_value("From Name", default="Chronicle AI") @@ -160,18 +164,26 @@ def main(): # Prompt to enable plugin enable_now = Confirm.ask("\nEnable email_summarizer plugin now?", default=True) if enable_now: - with open(plugins_yml_path, 'r') as f: + with open(plugins_yml_path, "r") as f: config = _yaml.load(f) - config['plugins']['email_summarizer']['enabled'] = True - with open(plugins_yml_path, 'w') as f: + config["plugins"]["email_summarizer"]["enabled"] = True + with open(plugins_yml_path, "w") as f: _yaml.dump(config, f) console.print("[green]✅ Plugin enabled in config/plugins.yml[/green]") - console.print("\n[bold cyan]✅ Email Summarizer configured successfully![/bold cyan]") + console.print( + "\n[bold cyan]✅ Email Summarizer configured successfully![/bold cyan]" + ) console.print("\n[bold]Configuration saved to:[/bold]") - console.print(" • [green]backends/advanced/.env[/green] - SMTP credentials (secrets)") - console.print(" • [green]config/plugins.yml[/green] - Plugin orchestration (enabled, events)") - console.print(" • [green]plugins/email_summarizer/config.yml[/green] - Plugin settings (already configured)") + console.print( + " • [green]backends/advanced/.env[/green] - SMTP credentials (secrets)" + ) + console.print( + " • [green]config/plugins.yml[/green] - Plugin orchestration (enabled, events)" + ) + console.print( + " • [green]plugins/email_summarizer/config.yml[/green] - Plugin settings (already configured)" + ) console.print() if not enable_now: @@ -183,11 +195,13 @@ def main(): console.print(" [dim]cd backends/advanced && docker compose restart[/dim]") console.print() console.print("[yellow]⚠️ SECURITY: Never commit secrets to git![/yellow]") - console.print("[yellow] • Secrets go in backends/advanced/.env (gitignored)[/yellow]") + console.print( + "[yellow] • Secrets go in backends/advanced/.env (gitignored)[/yellow]" + ) console.print("[yellow] • Config files use ${ENV_VAR} references only[/yellow]") -if __name__ == '__main__': +if __name__ == "__main__": try: main() except KeyboardInterrupt: @@ -196,5 +210,6 @@ def main(): except Exception as e: console.print(f"\n[red]Error during setup: {e}[/red]") import traceback + traceback.print_exc() sys.exit(1) diff --git a/plugins/email_summarizer/templates.py b/plugins/email_summarizer/templates.py index 9f99e5cb..bdc5b290 100644 --- a/plugins/email_summarizer/templates.py +++ b/plugins/email_summarizer/templates.py @@ -3,6 +3,7 @@ Provides HTML and plain text email templates. """ + import html from datetime import datetime from typing import Optional @@ -35,7 +36,7 @@ def format_html_email( transcript: str, conversation_id: str, duration: float, - created_at: Optional[datetime] = None + created_at: Optional[datetime] = None, ) -> str: """ Format HTML email template. @@ -58,7 +59,7 @@ def format_html_email( transcript_escaped = html.escape(transcript, quote=True) # Format transcript with line breaks (after escaping) - transcript_html = transcript_escaped.replace('\n', '
') + transcript_html = transcript_escaped.replace("\n", "
") return f""" @@ -209,7 +210,7 @@ def format_text_email( transcript: str, conversation_id: str, duration: float, - created_at: Optional[datetime] = None + created_at: Optional[datetime] = None, ) -> str: """ Format plain text email template. diff --git a/plugins/hermes/README.md b/plugins/hermes/README.md new file mode 100644 index 00000000..7ec625af --- /dev/null +++ b/plugins/hermes/README.md @@ -0,0 +1,46 @@ +# Hermes Agent Plugin + +Routes voice commands that start with (or contain) the keyword **`hermes`** to an +external [Hermes agent](https://github.com/) over its OpenAI-compatible HTTP API. + +When you say something like *"Hermes, what's on my calendar tomorrow?"*, the +router strips the `hermes` keyword and forwards the rest of the utterance to your +Hermes server's `POST /v1/chat/completions` endpoint. The agent's reply is +returned as the plugin result — Chronicle logs it and records it in the plugin +event log. + +## How it works + +1. Triggered on `transcript.streaming` via the `keyword_anywhere` condition + (`keywords: [hermes]` in `config/plugins.yml`). +2. The router strips `hermes` and passes the remaining text as `command`. +3. The plugin POSTs `{model, messages:[system, user]}` to `/v1/chat/completions`. +4. Conversation continuity: each request sends `X-Hermes-Session-Id: ` + so all utterances in one Chronicle conversation share one Hermes session. +5. The reply (`choices[0].message.content`) becomes the `PluginResult.message`. + +## Configuration + +Edit in the dashboard under **Plugins → Form → Hermes Agent**, or directly: + +- `config.yml` + - `api_url` — your Hermes server base URL (Tailscale MagicDNS name or LAN IP + + port, e.g. `http://hermes-rpi.your-tailnet.ts.net:8642`). A trailing `/v1` + is optional. + - `model` — model name advertised by the server (default `hermes`). + - `timeout` — request timeout in seconds (default `120`). + - `system_prompt` — system prompt sent with each request. +- `.env` + - `HERMES_API_KEY` — optional bearer token; leave empty for an + unauthenticated server. + +Use **Test Connection** in the UI to validate reachability and auth — it calls +`GET /v1/models` (cheap, no agent invocation). + +## Notes + +- The backend runs in Docker; `localhost` will **not** reach a Hermes server on + another machine. Use the RPi's Tailscale name or LAN IP. +- This plugin and the Home Assistant plugin must not share the same keyword. + Home Assistant is disabled by default here; if you re-enable it, give it a + different keyword. diff --git a/plugins/hermes/__init__.py b/plugins/hermes/__init__.py new file mode 100644 index 00000000..ebf99fc6 --- /dev/null +++ b/plugins/hermes/__init__.py @@ -0,0 +1,10 @@ +""" +Hermes Agent plugin for Chronicle. + +Routes voice commands prefixed with the "hermes" keyword to an external +Hermes agent over its OpenAI-compatible HTTP API and records the reply. +""" + +from .plugin import HermesPlugin + +__all__ = ["HermesPlugin"] diff --git a/plugins/hermes/config.yml b/plugins/hermes/config.yml new file mode 100644 index 00000000..cc5ce2b8 --- /dev/null +++ b/plugins/hermes/config.yml @@ -0,0 +1,40 @@ +# Hermes Agent Plugin Configuration +# +# Routes "hermes ..." voice commands to an external Hermes agent that exposes an +# OpenAI-compatible HTTP API (POST /v1/chat/completions). +# +# Plugin orchestration (enabled, events, trigger keyword) lives in +# config/plugins.yml. The API key (if your server requires one) is stored in +# plugins/hermes/.env as HERMES_API_KEY. + +# Hermes server base URL: scheme://host:port. Use your RPi's Tailscale MagicDNS +# name or its LAN IP. A trailing "/v1" is optional (added automatically). +# Examples: +# http://hermes-rpi.your-tailnet.ts.net:8642 +# http://192.168.1.50:8642 +api_url: "http://192.168.0.108:8642" + +# Model name advertised by your Hermes server. +model: "hermes" + +# Request timeout in seconds. Agent calls that use tools can take a while. +timeout: 120 + +# Optional extra system prompt. Hermes already has its own core system prompt / +# identity; anything set here is layered ON TOP of it (additive, not a +# replacement). Leave empty to use Hermes's own prompt unchanged. Use it only to +# inject Chronicle-specific context, e.g. "You're being spoken to through a +# wearable; keep replies short and suitable for text-to-speech." +system_prompt: "" + +# Optional bearer token for the Hermes server. Stored as a secret in .env. +api_key: ${HERMES_API_KEY} + +# Optional: also push Hermes's reply to Discord via the webhook "notify" route +# (deliver_only — no extra LLM cost). Leave webhook_url empty to disable the push. +# Use the full notify route URL (note: plural "/webhooks/"). +# Example: http://192.168.0.108:8644/webhooks/notify +webhook_url: "http://192.168.0.108:8644/webhooks/notify" + +# HMAC secret for the webhook (X-Hub-Signature-256). Stored as a secret in .env. +webhook_secret: ${HERMES_WEBHOOK_SECRET} diff --git a/plugins/hermes/plugin.py b/plugins/hermes/plugin.py new file mode 100644 index 00000000..e1ba1145 --- /dev/null +++ b/plugins/hermes/plugin.py @@ -0,0 +1,434 @@ +""" +Hermes Agent plugin for Chronicle. + +Listens for the "hermes" keyword in transcripts and forwards whatever the user +said (with the keyword stripped) to an external Hermes agent via its +OpenAI-compatible HTTP API (POST /v1/chat/completions). The agent's reply is +returned as the plugin result message, which Chronicle logs and records in the +plugin event log. + +Conversation continuity: each request carries an ``X-Hermes-Session-Id`` header +keyed by the Chronicle conversation id, so all utterances within one +conversation share the same Hermes-side session/context. +""" + +import hashlib +import hmac +import json +import logging +import time +from typing import Any, Dict, List, Optional + +import httpx +from advanced_omi_backend.plugins.base import BasePlugin, PluginContext, PluginResult + +logger = logging.getLogger(__name__) + + +def _normalize_base_url(api_url: str) -> str: + """Normalize a user-provided Hermes URL to a scheme://host:port base. + + Strips a trailing slash and a trailing ``/v1`` so the caller can append + ``/v1/`` exactly once regardless of how the user typed it. + + Examples: + "http://hermes.ts.net:8642/" -> "http://hermes.ts.net:8642" + "http://hermes.ts.net:8642/v1" -> "http://hermes.ts.net:8642" + """ + url = (api_url or "").strip().rstrip("/") + if url.endswith("/v1"): + url = url[: -len("/v1")].rstrip("/") + return url + + +class HermesPlugin(BasePlugin): + """ + Forward keyword-triggered voice commands to an external Hermes agent. + + Example: + User says: "Hermes, what's on my calendar tomorrow?" + -> Router detects keyword "hermes", strips it, passes command to on_transcript() + -> Plugin POSTs the command to Hermes /v1/chat/completions + -> Returns PluginResult with Hermes's reply as the message + """ + + SUPPORTED_ACCESS_LEVELS: List[str] = ["transcript"] + + name = "Hermes Agent" + description = 'Routes "hermes …" voice commands to an external Hermes agent over its OpenAI-compatible API' + + def __init__(self, config: Dict[str, Any]): + """ + Initialize the Hermes plugin. + + Args: + config: Plugin configuration with keys: + - api_url: Hermes server base URL (scheme://host:port, e.g. a + Tailscale MagicDNS name or LAN IP). ``/v1`` is optional. + - model: Model name advertised by the Hermes server (default "hermes") + - timeout: Read/response timeout in seconds (default 120) — the + agent may take a while to think, so this stays generous. + - connect_timeout: Connection-establish timeout in seconds + (default 5) — kept short so an unreachable host fails fast + instead of hanging for the whole response budget. + - system_prompt: System prompt sent with each request + - api_key: Optional bearer token (from HERMES_API_KEY in .env) + """ + super().__init__(config) + self.api_url = _normalize_base_url( + config.get("api_url", "http://localhost:8642") + ) + self.model = config.get("model", "hermes") + # Split the timeout: a short connect (fail fast when the agent host is + # unreachable) but a generous read (the agent is allowed to take its time + # to respond). A single flat timeout would make an unreachable host hang + # for the full response budget on the connect attempt. + self.timeout = int(config.get("timeout", 120)) + self.connect_timeout = float(config.get("connect_timeout", 5)) + # Hermes has its own core system prompt; anything here is layered on top. + # Empty by default so Hermes's own identity is used unchanged. + self.system_prompt = (config.get("system_prompt") or "").strip() + + # api_key comes from ${HERMES_API_KEY}; if the env var is unset the + # placeholder survives expansion, so treat that as "no key". + api_key = config.get("api_key", "") or "" + self.api_key = "" if "${" in api_key else api_key.strip() + + # Optional: also push the reply to Discord via the webhook "notify" route + # (deliver_only — no extra LLM cost). Disabled if either is unset. + self.webhook_url = (config.get("webhook_url") or "").strip().rstrip("/") + webhook_secret = config.get("webhook_secret", "") or "" + self.webhook_secret = "" if "${" in webhook_secret else webhook_secret.strip() + + self._client: Optional[httpx.AsyncClient] = None + + async def _push_to_discord(self, text: str) -> None: + """Best-effort push of text to the Hermes webhook "notify" route. + + Fire-and-forget: failures are logged but never affect the reply that + Chronicle already captured from the API server. Skipped if the webhook + URL or secret is not configured. + """ + if not (self.webhook_url and self.webhook_secret) or self._client is None: + return + try: + body = json.dumps({"text": text}).encode() + signature = ( + "sha256=" + + hmac.new( + self.webhook_secret.encode(), body, hashlib.sha256 + ).hexdigest() + ) + resp = await self._client.post( + self.webhook_url, + content=body, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + }, + ) + resp.raise_for_status() + logger.info("Pushed Hermes reply to Discord webhook") + except Exception as e: + logger.warning(f"Failed to push Hermes reply to Discord webhook: {e}") + + def _headers(self, session_id: Optional[str] = None) -> Dict[str, str]: + """Build request headers, including optional bearer auth and session id.""" + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + if session_id: + headers["X-Hermes-Session-Id"] = session_id + return headers + + async def initialize(self): + """Create the shared HTTP client. Raises if no api_url is configured.""" + if not self.enabled: + logger.info("Hermes plugin is disabled, skipping initialization") + return + + if not self.api_url: + raise ValueError( + "Hermes api_url is required (e.g. http://hermes-host:8642)" + ) + + logger.info( + f"Initializing Hermes plugin (URL: {self.api_url}, model: {self.model})" + ) + # Short connect, generous read/write/pool: a dead host fails in + # ~connect_timeout s instead of hanging for the full response budget. + self._client = httpx.AsyncClient( + timeout=httpx.Timeout(self.timeout, connect=self.connect_timeout) + ) + + # Best-effort connectivity check against the unauthenticated /health endpoint. + try: + resp = await self._client.get(f"{self.api_url}/health") + resp.raise_for_status() + logger.info("Hermes plugin initialized successfully") + except Exception as e: + # Don't hard-fail init: the RPi server may be momentarily down. Log + # loudly so the failure is visible, but let the plugin load so it can + # recover on the next utterance. + logger.warning( + f"Hermes health check failed during init ({self.api_url}): {e}" + ) + + async def cleanup(self): + """Close the shared HTTP client.""" + if self._client is not None: + await self._client.aclose() + self._client = None + + async def health_check(self) -> Dict[str, Any]: + """Live connectivity check against the Hermes /health endpoint.""" + if self._client is None: + return {"ok": False, "message": "HTTP client not initialized"} + try: + start = time.time() + resp = await self._client.get(f"{self.api_url}/health") + latency_ms = int((time.time() - start) * 1000) + resp.raise_for_status() + return {"ok": True, "message": "Hermes reachable", "latency_ms": latency_ms} + except Exception as e: + return {"ok": False, "message": f"Hermes unreachable: {e}"} + + async def _dispatch_command( + self, + command: str, + conversation_id: Optional[str], + empty_message: str, + ) -> PluginResult: + """Forward a resolved command to the Hermes agent and return its reply. + + Shared by both trigger paths so the acoustic wake word and the text + keyword reach the agent identically: + - ``on_transcript`` (text keyword, ``keyword_anywhere: [hermes]``) + - ``on_wake_word_detected`` (acoustic, from the wakeword-service) + + Args: + command: The command text (already stripped of any keyword). + conversation_id: Conversation id used as the Hermes session id. + empty_message: Message returned when ``command`` is empty. + """ + command = (command or "").strip() + + if not command: + return PluginResult( + success=False, + message=empty_message, + should_continue=True, + ) + + if self._client is None: + logger.error("Hermes HTTP client not initialized") + return PluginResult( + success=False, + message="Sorry, Hermes is not connected.", + should_continue=True, + ) + + # Hermes applies its own core system prompt; only send one if the user + # configured extra context to layer on top. + messages = [] + if self.system_prompt: + messages.append({"role": "system", "content": self.system_prompt}) + messages.append({"role": "user", "content": command}) + + payload = { + "model": self.model, + "messages": messages, + "stream": False, + } + + try: + logger.info(f"Forwarding command to Hermes: '{command}'") + resp = await self._client.post( + f"{self.api_url}/v1/chat/completions", + json=payload, + headers=self._headers(session_id=conversation_id), + ) + resp.raise_for_status() + data = resp.json() + + reply = data["choices"][0]["message"]["content"].strip() + logger.info(f"Hermes replied ({len(reply)} chars)") + + # Best-effort push to Discord (#hermes) via the notify webhook. + # Quote the spoken request (Discord blockquote) above the reply so + # the channel has context. Each line is prefixed for multi-line input. + quoted = "\n".join(f"> {line}" for line in command.splitlines()) + discord_text = f"{quoted}\n\n{reply}" if quoted else reply + await self._push_to_discord(discord_text) + + return PluginResult( + success=True, + message=reply, + data={ + "command": command, + "reply": reply, + "conversation_id": conversation_id, + "model": data.get("model", self.model), + }, + # The utterance was directed at Hermes; consume it so other + # transcript plugins don't also act on it. + should_continue=False, + ) + + except httpx.HTTPStatusError as e: + body = e.response.text[:300] if e.response is not None else "" + logger.error(f"Hermes returned HTTP {e.response.status_code}: {body}") + return PluginResult( + success=False, + message=f"Hermes returned an error (HTTP {e.response.status_code}).", + should_continue=True, + ) + except (KeyError, IndexError, ValueError) as e: + logger.error(f"Unexpected Hermes response shape: {e}") + return PluginResult( + success=False, + message="Hermes returned an unexpected response.", + should_continue=True, + ) + except (httpx.ConnectError, httpx.ConnectTimeout) as e: + # Host unreachable / refused — the short connect timeout fires here. + logger.error(f"Could not connect to Hermes ({self.api_url}): {e!r}") + return PluginResult( + success=False, + message="Sorry, I couldn't reach Hermes.", + should_continue=True, + ) + except (httpx.ReadTimeout, httpx.PoolTimeout, httpx.WriteTimeout) as e: + # Connected fine but Hermes never sent a response within the read + # budget — a hung/overloaded agent, NOT a connectivity problem. + logger.error( + f"Hermes connected but did not respond within {self.timeout}s " + f"(read timeout): {e!r}" + ) + return PluginResult( + success=False, + message="Hermes took too long to respond.", + should_continue=True, + ) + except Exception as e: + logger.error(f"Failed to reach Hermes: {e!r}") + return PluginResult( + success=False, + message=f"Couldn't reach Hermes: {e}", + should_continue=True, + ) + + async def on_transcript(self, context: PluginContext) -> Optional[PluginResult]: + """ + Forward a keyword-triggered command to Hermes and return its reply. + + The router has already detected the "hermes" keyword and stripped it, + placing the remaining text in ``context.data["command"]``. + """ + return await self._dispatch_command( + command=context.data.get("command"), + conversation_id=context.data.get("conversation_id"), + empty_message="I heard the Hermes keyword but no command followed it.", + ) + + async def on_wake_word_detected( + self, context: PluginContext + ) -> Optional[PluginResult]: + """ + Forward an acoustically-triggered command to Hermes. + + The standalone wakeword-service detected the acoustic "Hermes" wake word, + captured the following turn, and resolved its text from the existing + transcription — placed in ``context.data["command"]``. This shares the + exact agent-call path as the text keyword trigger. + """ + # The wakeword-service silence-gates near-silent captures: it didn't run + # batch ASR because the turn held no real speech (a false arm). Don't bug + # the agent or speak a misleading "couldn't make out the command" — just + # drop it quietly. The skip is still visible via asr_status in the SSE/log. + asr_status = context.data.get("asr_status") + if asr_status == "skipped_silence": + logger.info("Hermes wake word armed but capture was silent; ignoring") + # message stays empty so nothing is spoken/surfaced as a reply; the + # detail lives in `data` so the Events page can explain the no-op + # instead of showing a bare "Error". + return PluginResult( + success=False, + message="", + data={ + "skipped": True, + "skip_reason": "silent_capture", + "detail": "Wake word armed but the capture held no speech; " + "batch ASR was skipped (silence gate).", + }, + should_continue=True, + ) + return await self._dispatch_command( + command=context.data.get("command"), + conversation_id=context.data.get("conversation_id"), + empty_message="I heard the Hermes wake word but couldn't make out the command.", + ) + + @staticmethod + async def test_connection(config: Dict[str, Any]) -> Dict[str, Any]: + """ + Validate Hermes connectivity (and auth, if configured) without invoking + the agent. Used by the form-based configuration UI. + + Hits ``GET /v1/models`` with the configured bearer token. This confirms + both reachability and that the API key (if any) is accepted, while being + far cheaper than a full chat completion. + + Args: + config: Flat config dict from the test UI. Settings keep their + original keys (api_url, model); env vars arrive lowercased + (hermes_api_key). + + Returns: + Dict with success status, message, and optional details. + """ + api_url = _normalize_base_url(config.get("api_url", "")) + if not api_url: + return { + "success": False, + "message": "api_url is required (e.g. http://hermes-host:8642)", + "status": "error", + } + + # Settings expose api_key as-is; the env-var path lowercases to hermes_api_key. + api_key = config.get("api_key") or config.get("hermes_api_key") or "" + if "${" in api_key: + api_key = "" + + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + try: + start = time.time() + async with httpx.AsyncClient( + timeout=httpx.Timeout(10, connect=5) + ) as client: + resp = await client.get(f"{api_url}/v1/models", headers=headers) + latency_ms = int((time.time() - start) * 1000) + + if resp.status_code in (401, 403): + return { + "success": False, + "message": "Hermes rejected the API key (check HERMES_API_KEY).", + "status": "error", + } + resp.raise_for_status() + + return { + "success": True, + "message": f"Connected to Hermes at {api_url} ({latency_ms}ms)", + "status": "ok", + "latency_ms": latency_ms, + } + except Exception as e: + return { + "success": False, + "message": f"Could not reach Hermes at {api_url}: {e}", + "status": "error", + } diff --git a/plugins/homeassistant/__init__.py b/plugins/homeassistant/__init__.py index 11b831e9..54b3f5d0 100644 --- a/plugins/homeassistant/__init__.py +++ b/plugins/homeassistant/__init__.py @@ -6,4 +6,4 @@ from .plugin import HomeAssistantPlugin -__all__ = ['HomeAssistantPlugin'] +__all__ = ["HomeAssistantPlugin"] diff --git a/plugins/homeassistant/command_parser.py b/plugins/homeassistant/command_parser.py index cc73626d..de56ce97 100644 --- a/plugins/homeassistant/command_parser.py +++ b/plugins/homeassistant/command_parser.py @@ -51,16 +51,16 @@ class ParsedCommand: - set_color: Set color TARGET_TYPE (choose one): -- area: Targeting all entities of a type in an area (e.g., "study lights") -- all_in_area: Targeting ALL entities in an area (e.g., "everything in study") -- entity: Targeting a specific entity by name (e.g., "desk lamp") +- area: Target entities in a specific area OR label. Labels are groups of areas (e.g., "hall" label might cover dining_room + living_room). Both areas and labels are valid targets. +- all: Target entities across ALL areas (e.g., "all lights", "every light") +- entity: Target a specific entity by name (e.g., "desk lamp") ENTITY_TYPE (optional, use null if not specified): - light: Light entities - switch: Switch entities - fan: Fan entities - cover: Covers/blinds -- null: All entity types (when target_type is "all_in_area") +- null: All entity types PARAMETERS (optional, empty dict if none): - brightness_pct: Brightness percentage (0-100) @@ -71,8 +71,8 @@ class ParsedCommand: Command: "turn off study lights" Response: {"action": "turn_off", "target_type": "area", "target": "study", "entity_type": "light", "parameters": {}} -Command: "turn off everything in study" -Response: {"action": "turn_off", "target_type": "all_in_area", "target": "study", "entity_type": null, "parameters": {}} +Command: "turn off hall lights" +Response: {"action": "turn_off", "target_type": "area", "target": "hall", "entity_type": "light", "parameters": {}} Command: "turn on desk lamp" Response: {"action": "turn_on", "target_type": "entity", "target": "desk lamp", "entity_type": null, "parameters": {}} @@ -80,11 +80,11 @@ class ParsedCommand: Command: "set study lights to 50%" Response: {"action": "set_brightness", "target_type": "area", "target": "study", "entity_type": "light", "parameters": {"brightness_pct": 50}} -Command: "turn on living room fan" -Response: {"action": "turn_on", "target_type": "area", "target": "living room", "entity_type": "fan", "parameters": {}} - Command: "turn off all lights" -Response: {"action": "turn_off", "target_type": "entity", "target": "all", "entity_type": "light", "parameters": {}} +Response: {"action": "turn_off", "target_type": "all", "target": "all", "entity_type": "light", "parameters": {}} + +Command: "turn off everything" +Response: {"action": "turn_off", "target_type": "all", "target": "all", "entity_type": null, "parameters": {}} Command: "toggle hallway light" Response: {"action": "toggle", "target_type": "entity", "target": "hallway light", "entity_type": null, "parameters": {}} @@ -94,4 +94,6 @@ class ParsedCommand: 2. Use lowercase for action, target_type, target, entity_type 3. Use null (not "null" string) for missing entity_type 4. Always include all 5 fields: action, target_type, target, entity_type, parameters +5. The "target" for target_type "area" MUST be an area name or label name from the provided context +6. Use target_type "all" when the user says "all lights", "every light", "all the lights", etc. """ diff --git a/plugins/homeassistant/config.yml b/plugins/homeassistant/config.yml index 8b410694..f674d3bb 100644 --- a/plugins/homeassistant/config.yml +++ b/plugins/homeassistant/config.yml @@ -9,10 +9,13 @@ ha_url: ${HA_URL} ha_token: ${HA_TOKEN} # Command configuration -wake_word: ${HA_WAKE_WORD:-vivi} +wake_word: ${HA_WAKE_WORD:-hermes} timeout: ${HA_TIMEOUT:-30} # Button action mappings +# Maps device button events to Home Assistant service calls. +# Each key is a button event type (single_press, double_press). +# Values specify: service, target_type (area/entity), target, entity_type. button_actions: double_press: service: toggle diff --git a/plugins/homeassistant/config.yml.backup b/plugins/homeassistant/config.yml.backup deleted file mode 100644 index eb477aa5..00000000 --- a/plugins/homeassistant/config.yml.backup +++ /dev/null @@ -1,13 +0,0 @@ -# Home Assistant Plugin Configuration -# -# This file contains non-secret configuration for the Home Assistant plugin. -# Secrets (HA_TOKEN) are stored in backends/advanced/.env -# Plugin orchestration (enabled, events, condition) is in config/plugins.yml - -# Home Assistant server configuration -ha_url: ${HA_URL} -ha_token: ${HA_TOKEN} - -# Command configuration -wake_word: ${HA_WAKE_WORD:-vivi} -timeout: ${HA_TIMEOUT:-30} diff --git a/plugins/homeassistant/entity_cache.py b/plugins/homeassistant/entity_cache.py index ab8e07b0..0b02ae52 100644 --- a/plugins/homeassistant/entity_cache.py +++ b/plugins/homeassistant/entity_cache.py @@ -50,20 +50,24 @@ def find_entity_by_name(self, name: str) -> Optional[str]: # Step 1: Exact friendly_name match for entity_id, details in self.entity_details.items(): - friendly_name = details.get('attributes', {}).get('friendly_name', '') + friendly_name = details.get("attributes", {}).get("friendly_name", "") if friendly_name.lower() == name_lower: - logger.debug(f"Exact match: {name} → {entity_id} (friendly_name: {friendly_name})") + logger.debug( + f"Exact match: {name} → {entity_id} (friendly_name: {friendly_name})" + ) return entity_id # Step 2: Partial friendly_name match for entity_id, details in self.entity_details.items(): - friendly_name = details.get('attributes', {}).get('friendly_name', '') + friendly_name = details.get("attributes", {}).get("friendly_name", "") if name_lower in friendly_name.lower(): - logger.debug(f"Partial match: {name} → {entity_id} (friendly_name: {friendly_name})") + logger.debug( + f"Partial match: {name} → {entity_id} (friendly_name: {friendly_name})" + ) return entity_id # Step 3: Entity ID match (try adding common domains) - common_domains = ['light', 'switch', 'fan', 'cover'] + common_domains = ["light", "switch", "fan", "cover"] for domain in common_domains: candidate_id = f"{domain}.{name_lower.replace(' ', '_')}" if candidate_id in self.entity_details: @@ -74,9 +78,7 @@ def find_entity_by_name(self, name: str) -> Optional[str]: return None def get_entities_in_area( - self, - area: str, - entity_type: Optional[str] = None + self, area: str, entity_type: Optional[str] = None ) -> List[str]: """ Get all entities in an area, optionally filtered by domain. @@ -121,10 +123,7 @@ def get_entities_in_area( # Filter by entity type if specified if entity_type: entity_type_lower = entity_type.lower() - entities = [ - e for e in entities - if e.split('.')[0] == entity_type_lower - ] + entities = [e for e in entities if e.split(".")[0] == entity_type_lower] logger.debug( f"Found {len(entities)} entities in area '{area}'" diff --git a/plugins/homeassistant/intent_router/__init__.py b/plugins/homeassistant/intent_router/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/homeassistant/intent_router/cascade.py b/plugins/homeassistant/intent_router/cascade.py new file mode 100644 index 00000000..d7cf8d07 --- /dev/null +++ b/plugins/homeassistant/intent_router/cascade.py @@ -0,0 +1,179 @@ +"""Tiered command cascade: router -> /conversation -> fuzzy-LLM -> Hermes. + +Pure logic, dependency-injected so the SAME code path runs in: + - the Home Assistant plugin (on_wake_word_detected), and + - the standalone route_test.py harness. + +Injected callables (all async): + conversation_fn(text) -> ConvResult (HA /api/conversation/process) + llm_complete_fn(system, user) -> str (any chat LLM) + hermes_fn(text) -> str (Hermes agent reply) + +The cascade owns the Tier-2 "fuzzy intent -> canonical HA commands" prompt so +both callers translate identically. +""" + +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Awaitable, Callable, List, Optional + +logger = logging.getLogger(__name__) + +# Areas/labels known to HA Assist (used to ground the Tier-2 translator). +DEFAULT_AREAS = ["hall", "living room", "dining room", "study"] + +# Vocabulary Home Assistant understands via /conversation: built-in intents plus +# our custom_sentences in ha-config (relative_light.yaml + intent_script.yaml -- +# warmer/cooler/dimmer/brighter/cozy/focus). The Tier-2 LLM only runs when the raw +# command didn't match HA directly; it maps a novel/fuzzy request onto these +# commands and HA executes them. (No hardcoded colour/brightness values here -- +# "warm white" via /conversation is broken on our bulbs; the relative commands and +# cozy/focus do the actual color_temp_kelvin work HA-side.) +_TRANSLATE_SYSTEM = """You convert a casual smart-home lighting request into one or more explicit Home Assistant voice commands. Home Assistant executes exactly what you output. + +Available areas: {areas} (omit the area to mean all lights that are on). + +Use ONLY these command patterns: + - "turn on [the] [] lights" / "turn off [the] [] lights" + - "set the [] lights to percent" + - "make [the] [] lights warmer" / "make [the] [] lights cooler" + - "dim [the] [] lights" / "brighten [the] [] lights" + - "make it cozy" (= warmer + dimmer) + - "focus" (= cooler + brighter) + +Guidance: + - relaxing / soothing / soft / evening / movie / bedtime / romantic -> "make it cozy" + - working / reading / alert -> "focus" + - "too bright" -> "dim the lights"; "too dark" -> "brighten the lights" + - "too warm/orange" -> "make the lights cooler"; "too cold/blue" -> "make the lights warmer" + - For a named room, include the area (e.g. "make the study lights warmer"). + - If the request is NOT about controlling lights, output {{"commands": []}}. + +Output STRICT JSON only: {{"commands": ["...", "..."]}}""" + + +@dataclass +class RouteInfo: + """What an intent-router classification returns (route + confidence).""" + + route: str # "home" | "other" + p_home: float + latency_ms: float = 0.0 + + +@dataclass +class ConvResult: + response_type: str # action_done | query_answer | error + code: Optional[str] + speech: str + success_count: int # entities actually touched + + @property + def confident_hit(self) -> bool: + """Did HA handle the command? + + The intent router already gated chat out before /conversation, so any + matched HA intent counts -- built-in OR our custom_sentences. Custom + intent_scripts (warmer/cozy/dimmer...) return action_done with an empty + 'success' list, so we must NOT require success_count > 0 here. + """ + return self.response_type in ("action_done", "query_answer") + + +@dataclass +class TierTrace: + tier: str + detail: str + latency_ms: float + + +@dataclass +class CascadeResult: + command: str + final_tier: str = "" + message: str = "" + success: bool = False + traces: List[TierTrace] = field(default_factory=list) + total_ms: float = 0.0 + + +def parse_translate(text: str) -> List[str]: + """Parse the Tier-2 LLM JSON into a list of canonical commands.""" + text = (text or "").strip() + if text.startswith("```"): + lines = text.split("\n") + text = "\n".join(lines[1:-1]) if len(lines) > 2 else text + text = text.strip() + try: + data = json.loads(text) + cmds = data.get("commands", []) + return [c for c in cmds if isinstance(c, str) and c.strip()] + except Exception as e: + logger.warning("Tier-2 translate parse failed: %s (raw=%r)", e, text[:200]) + return [] + + +async def run_ha_cascade( + command: str, + *, + conversation_fn: Callable[[str], Awaitable[ConvResult]], + llm_complete_fn: Callable[[str, str], Awaitable[str]], + areas: Optional[List[str]] = None, +) -> CascadeResult: + """Try to handle a command with Home Assistant: /conversation (fast path) + -> LLM maps a novel fuzzy request onto HA's vocabulary, re-run via /conversation. + + Returns a CascadeResult whose ``success`` is True iff HA actually handled the + command. On False, the caller declines (returns None) so the plugin chain + passes the command to the next handler (e.g. the Hermes agent). This function + knows nothing about Hermes or the intent router - those live one layer up. + """ + areas = areas or DEFAULT_AREAS + t_start = time.time() + res = CascadeResult(command=command) + + # ---- Tier 1: HA /conversation (fast path) ---- + t = time.time() + conv = await conversation_fn(command) + res.traces.append( + TierTrace( + "ha_conversation", + f"{conv.response_type}/{conv.code} touched={conv.success_count} :: {conv.speech}", + (time.time() - t) * 1000, + ) + ) + if conv.confident_hit: + res.final_tier, res.message, res.success = "ha_conversation", conv.speech, True + res.total_ms = (time.time() - t_start) * 1000 + return res + + # ---- Tier 2: LLM maps a novel fuzzy request onto HA's vocabulary ---- + t = time.time() + tier2_label = "ha_fuzzy_llm" + sys_prompt = _TRANSLATE_SYSTEM.format(areas=", ".join(areas)) + raw = await llm_complete_fn(sys_prompt, command) + cmds = parse_translate(raw) + res.traces.append( + TierTrace("ha_fuzzy_llm", f"translated -> {cmds}", (time.time() - t) * 1000) + ) + + if cmds: + executed, speeches = 0, [] + for c in cmds: + cr = await conversation_fn(c) + if cr.response_type in ("action_done", "query_answer") and cr.code is None: + executed += 1 + speeches.append(cr.speech) + if executed: + res.final_tier = tier2_label + res.message = "; ".join(s for s in speeches if s) or "Done" + res.success = True + res.total_ms = (time.time() - t_start) * 1000 + return res + + # Not a home command HA could act on -> decline; the chain moves on. + res.final_tier, res.success = "declined", False + res.total_ms = (time.time() - t_start) * 1000 + return res diff --git a/plugins/homeassistant/intent_router/route_test b/plugins/homeassistant/intent_router/route_test new file mode 100755 index 00000000..b6db2bd0 --- /dev/null +++ b/plugins/homeassistant/intent_router/route_test @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Wrapper: run the end-to-end router + HA cascade harness with deps via uv. +# ./route_test "make it more soothing for my eyes" +# ./route_test "what's the meaning of life" +cd "$(dirname "$0")" || exit 1 +exec uv run --with openai --with httpx --with python-dotenv \ + python3 route_test.py "$@" diff --git a/plugins/homeassistant/intent_router/router_client.py b/plugins/homeassistant/intent_router/router_client.py new file mode 100644 index 00000000..c18da2dd --- /dev/null +++ b/plugins/homeassistant/intent_router/router_client.py @@ -0,0 +1,48 @@ +"""HTTP client for the intent-router microservice. + +Keeps the heavy ML deps (model2vec, scikit-learn) out of the backend image: the +classifier runs in the separate `intent-router` service and we call it over a +fast localhost/compose-network HTTP hop (~1-3ms on top of ~0.14ms inference). + +Fail-open policy: if the service is unreachable, default to route='home' so the +command still enters the HA cascade (which itself falls back to Hermes). A router +outage therefore degrades latency, never correctness. +""" + +import logging +import os +import time + +import httpx + +from .cascade import RouteInfo + +logger = logging.getLogger(__name__) + +INTENT_ROUTER_URL = os.getenv("INTENT_ROUTER_URL", "http://intent-router:8791") +_TIMEOUT = float(os.getenv("INTENT_ROUTER_TIMEOUT", "2.0")) + +_client: httpx.AsyncClient | None = None + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None: + _client = httpx.AsyncClient(timeout=_TIMEOUT) + return _client + + +async def classify(text: str) -> RouteInfo: + t0 = time.time() + try: + resp = await _get_client().post( + f"{INTENT_ROUTER_URL}/classify", json={"text": text} + ) + resp.raise_for_status() + d = resp.json() + return RouteInfo( + route=d["route"], p_home=d["p_home"], latency_ms=d.get("latency_ms", 0.0) + ) + except Exception as e: + logger.warning("intent-router unreachable (%s); failing open to 'home'", e) + return RouteInfo(route="home", p_home=1.0, latency_ms=(time.time() - t0) * 1000) diff --git a/plugins/homeassistant/mcp_client.py b/plugins/homeassistant/mcp_client.py index d0eb603e..eb3ff28f 100644 --- a/plugins/homeassistant/mcp_client.py +++ b/plugins/homeassistant/mcp_client.py @@ -16,6 +16,7 @@ class MCPError(Exception): """MCP protocol error""" + pass @@ -37,11 +38,17 @@ def __init__(self, base_url: str, token: str, timeout: int = 30): timeout: Request timeout in seconds """ - self.base_url = base_url.rstrip('/') + self.base_url = base_url.rstrip("/") self.mcp_url = f"{self.base_url}/api/mcp" self.token = token self.timeout = timeout - self.client = httpx.AsyncClient(timeout=timeout) + # Cap the connect phase at 5s: HA is on the local network, so a healthy + # connect is near-instant. This keeps startup probes and background + # recovery retries snappy when the HA server is off, while long reads + # (Assist pipeline) still get the full timeout. + self.client = httpx.AsyncClient( + timeout=httpx.Timeout(timeout, connect=min(5.0, timeout)) + ) self._request_id = 0 async def close(self): @@ -53,7 +60,9 @@ def _next_request_id(self) -> int: self._request_id += 1 return self._request_id - async def _send_mcp_request(self, method: str, params: Optional[Dict] = None) -> Dict[str, Any]: + async def _send_mcp_request( + self, method: str, params: Optional[Dict] = None + ) -> Dict[str, Any]: """ Send MCP protocol request to Home Assistant. @@ -67,26 +76,20 @@ async def _send_mcp_request(self, method: str, params: Optional[Dict] = None) -> Raises: MCPError: If request fails or returns an error """ - payload = { - "jsonrpc": "2.0", - "id": self._next_request_id(), - "method": method - } + payload = {"jsonrpc": "2.0", "id": self._next_request_id(), "method": method} if params: payload["params"] = params headers = { "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json" + "Content-Type": "application/json", } try: logger.debug(f"MCP Request: {method} with params: {params}") response = await self.client.post( - self.mcp_url, - json=payload, - headers=headers + self.mcp_url, json=payload, headers=headers ) response.raise_for_status() @@ -109,6 +112,46 @@ async def _send_mcp_request(self, method: str, params: Optional[Dict] = None) -> logger.error(f"Unexpected error calling MCP endpoint: {e}") raise MCPError(f"Unexpected error: {e}") + async def process_conversation( + self, text: str, language: str = "en" + ) -> Dict[str, Any]: + """Send text to Home Assistant Assist (/api/conversation/process). + + HA runs its local intent NLU, executes any matched intent, and returns a + natural-language reply. Returns a parsed dict: + {response_type, code, speech, success_count} + - response_type: action_done | query_answer | error + - code: e.g. 'no_intent_match' / 'no_valid_targets' on a miss, else None + - success_count: number of entities actually controlled (0 for info + intents like the time, which lets callers reject those false matches) + """ + url = f"{self.base_url}/api/conversation/process" + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + try: + resp = await self.client.post( + url, json={"text": text, "language": language}, headers=headers + ) + resp.raise_for_status() + d = resp.json().get("response", {}) + data = d.get("data", {}) or {} + return { + "response_type": d.get("response_type", "error"), + "code": data.get("code"), + "speech": d.get("speech", {}).get("plain", {}).get("speech", ""), + "success_count": len(data.get("success", []) or []), + } + except Exception as e: + logger.error(f"HA conversation/process failed: {e}") + return { + "response_type": "error", + "code": "request_failed", + "speech": "", + "success_count": 0, + } + async def list_tools(self) -> List[Dict[str, Any]]: """ Get list of available MCP tools from Home Assistant. @@ -133,7 +176,9 @@ async def list_tools(self) -> List[Dict[str, Any]]: logger.info(f"Retrieved {len(tools)} tools from Home Assistant MCP") return tools - async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + async def call_tool( + self, tool_name: str, arguments: Dict[str, Any] + ) -> Dict[str, Any]: """ Execute a tool via MCP. @@ -151,10 +196,7 @@ async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str >>> await client.call_tool("turn_off", {"entity_id": "light.hall_light"}) {"success": True} """ - params = { - "name": tool_name, - "arguments": arguments - } + params = {"name": tool_name, "arguments": arguments} logger.info(f"Calling MCP tool '{tool_name}' with args: {arguments}") result = await self._send_mcp_request("tools/call", params) @@ -178,7 +220,9 @@ async def test_connection(self) -> bool: """ try: tools = await self.list_tools() - logger.info(f"MCP connection test successful ({len(tools)} tools available)") + logger.info( + f"MCP connection test successful ({len(tools)} tools available)" + ) return True except Exception as e: logger.error(f"MCP connection test failed: {e}") @@ -203,7 +247,7 @@ async def _render_template(self, template: str) -> Any: """ headers = { "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json" + "Content-Type": "application/json", } payload = {"template": template} @@ -211,16 +255,14 @@ async def _render_template(self, template: str) -> Any: try: logger.debug(f"Rendering template: {template}") response = await self.client.post( - f"{self.base_url}/api/template", - json=payload, - headers=headers + f"{self.base_url}/api/template", json=payload, headers=headers ) response.raise_for_status() result = response.text.strip() # Try to parse as JSON (for lists, dicts) - if result.startswith('[') or result.startswith('{'): + if result.startswith("[") or result.startswith("{"): try: return json.loads(result) except json.JSONDecodeError: @@ -278,7 +320,9 @@ async def fetch_label_areas(self, label: str) -> List[str]: logger.info(f"Label '{label}' maps to {len(areas)} areas: {areas}") return areas else: - logger.warning(f"Unexpected label_areas format for '{label}': {type(areas)}") + logger.warning( + f"Unexpected label_areas format for '{label}': {type(areas)}" + ) return [] async def fetch_area_entities(self, area_name: str) -> List[str]: @@ -302,7 +346,9 @@ async def fetch_area_entities(self, area_name: str) -> List[str]: logger.info(f"Fetched {len(entities)} entities from area '{area_name}'") return entities else: - logger.warning(f"Unexpected entities format for area '{area_name}': {type(entities)}") + logger.warning( + f"Unexpected entities format for area '{area_name}': {type(entities)}" + ) return [] async def fetch_entity_states(self) -> Dict[str, Dict]: @@ -324,14 +370,13 @@ async def fetch_entity_states(self) -> Dict[str, Dict]: """ headers = { "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json" + "Content-Type": "application/json", } try: logger.debug("Fetching all entity states") response = await self.client.get( - f"{self.base_url}/api/states", - headers=headers + f"{self.base_url}/api/states", headers=headers ) response.raise_for_status() @@ -340,16 +385,16 @@ async def fetch_entity_states(self) -> Dict[str, Dict]: # Enrich with area information for state in states: - entity_id = state.get('entity_id') + entity_id = state.get("entity_id") if entity_id: # Get area_id using Template API try: area_template = f"{{{{ area_id('{entity_id}') }}}}" area_id = await self._render_template(area_template) - state['area_id'] = area_id if area_id else None + state["area_id"] = area_id if area_id else None except Exception as e: logger.debug(f"Failed to get area for {entity_id}: {e}") - state['area_id'] = None + state["area_id"] = None entity_details[entity_id] = state @@ -364,11 +409,7 @@ async def fetch_entity_states(self) -> Dict[str, Dict]: raise MCPError(f"Request failed: {e}") async def call_service( - self, - domain: str, - service: str, - entity_ids: List[str], - **parameters + self, domain: str, service: str, entity_ids: List[str], **parameters ) -> Dict[str, Any]: """ Call a Home Assistant service directly via REST API. @@ -388,24 +429,21 @@ async def call_service( """ headers = { "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json" + "Content-Type": "application/json", } - payload = { - "entity_id": entity_ids, - **parameters - } + payload = {"entity_id": entity_ids, **parameters} service_url = f"{self.base_url}/api/services/{domain}/{service}" try: - logger.info(f"Calling service {domain}.{service} for {len(entity_ids)} entities") + logger.info( + f"Calling service {domain}.{service} for {len(entity_ids)} entities" + ) logger.debug(f"Service payload: {payload}") response = await self.client.post( - service_url, - json=payload, - headers=headers + service_url, json=payload, headers=headers ) response.raise_for_status() diff --git a/plugins/homeassistant/plugin.py b/plugins/homeassistant/plugin.py index b94f8ae4..d178d966 100644 --- a/plugins/homeassistant/plugin.py +++ b/plugins/homeassistant/plugin.py @@ -2,15 +2,31 @@ Home Assistant plugin for Chronicle. Enables control of Home Assistant devices through natural language commands -triggered by a wake word. +triggered by a keyword anywhere in the transcript. """ +import asyncio import json import logging +import time +from datetime import datetime from typing import Any, Dict, List, Optional -from advanced_omi_backend.plugins.base import BasePlugin, PluginContext, PluginResult +from advanced_omi_backend.llm_client import get_llm_client +from advanced_omi_backend.openai_factory import model_supports_temperature +from advanced_omi_backend.plugins.base import ( + BasePlugin, + PluginConnectivityError, + PluginContext, + PluginResult, +) +from advanced_omi_backend.plugins.events import PluginEvent +from advanced_omi_backend.prompt_registry import get_prompt_registry + +from .command_parser import COMMAND_PARSER_SYSTEM_PROMPT, ParsedCommand from .entity_cache import EntityCache +from .intent_router import router_client +from .intent_router.cascade import ConvResult, run_ha_cascade from .mcp_client import HAMCPClient, MCPError logger = logging.getLogger(__name__) @@ -18,20 +34,20 @@ class HomeAssistantPlugin(BasePlugin): """ - Plugin for controlling Home Assistant devices via wake word commands. + Plugin for controlling Home Assistant devices via keyword commands. Example: - User says: "Vivi, turn off the hall lights" - -> Wake word "vivi" detected by router - -> Command "turn off the hall lights" passed to on_transcript() - -> Plugin parses command and calls HA MCP to execute + User says: "Turn off the hall lights, VV" + -> Keyword "vv" detected anywhere in transcript by router + -> Command "Turn off the hall lights" passed to on_transcript() + -> Plugin parses command and calls HA to execute -> Returns: PluginResult with "I've turned off the hall light" """ SUPPORTED_ACCESS_LEVELS: List[str] = ["transcript", "button"] name = "Home Assistant" - description = "Wake word device control with Home Assistant integration" + description = "Keyword-triggered device control with Home Assistant integration" def __init__(self, config: Dict[str, Any]): """ @@ -58,14 +74,12 @@ def __init__(self, config: Dict[str, Any]): # Configuration self.ha_url = config.get("ha_url", "http://localhost:8123") self.ha_token = config.get("ha_token", "") - self.wake_word = config.get("wake_word", "vivi") + self.wake_word = config.get("wake_word", "hermes") self.timeout = int(config.get("timeout", 30)) self.button_actions = config.get("button_actions", {}) def register_prompts(self, registry) -> None: """Register Home Assistant prompts with the prompt registry.""" - from .command_parser import COMMAND_PARSER_SYSTEM_PROMPT - registry.register_default( "plugin.homeassistant.command_parser", template=COMMAND_PARSER_SYSTEM_PROMPT, @@ -82,7 +96,10 @@ async def initialize(self): Connects to Home Assistant MCP server and discovers available tools. Raises: - MCPError: If connection or discovery fails + ValueError: If required configuration is missing (not retried) + PluginConnectivityError: If HA is unreachable — the plugin system + marks the plugin degraded and retries in the background (HA + runs on a server that may be off when Chronicle boots) """ if not self.enabled: logger.info("Home Assistant plugin is disabled, skipping initialization") @@ -93,6 +110,13 @@ async def initialize(self): logger.info(f"Initializing Home Assistant plugin (URL: {self.ha_url})") + # Re-init (background recovery) replaces the client; close the old one + if self.mcp_client: + try: + await self.mcp_client.close() + except Exception: # noqa: BLE001 — old client may already be dead + pass + # Create MCP client (used for REST API calls, not MCP protocol) self.mcp_client = HAMCPClient( base_url=self.ha_url, token=self.ha_token, timeout=self.timeout @@ -106,10 +130,114 @@ async def initialize(self): raise ValueError(f"Unexpected template result: {test_result}") logger.info("Home Assistant API connection successful") except Exception as e: - raise MCPError(f"Failed to connect to Home Assistant API: {e}") + # Transient by design: the handlers degrade gracefully while HA is + # down (mcp_client stays usable once HA returns), so signal + # "retry later" instead of a hard init failure. + raise PluginConnectivityError( + f"Home Assistant unreachable at {self.ha_url}: {e}" + ) from e + + # Warm the HA Assist pipeline so the first real command isn't slow. + # (The intent-router microservice warms its own model on startup.) + try: + await self.mcp_client.process_conversation("status") + logger.info("HA Assist warmed") + except Exception as e: + logger.warning(f"Warm-up skipped: {e}") logger.info("Home Assistant plugin initialized successfully") + async def on_wake_word_detected( + self, context: PluginContext + ) -> Optional[PluginResult]: + """First handler in the wake-word chain of responsibility. + + Decides "is this command for me (Home Assistant)?": + - intent router says 'other' -> decline (return None) -> next plugin + - router says 'home' and HA acts -> handle, stop the chain + - router says 'home' but HA can't -> decline (return None) -> next plugin + + Returning None passes the command down the priority-ordered plugin chain + (the Hermes agent is the catch-all, last). This plugin has NO knowledge of + Hermes - the ordering/hierarchy lives in config/plugins.yml (priority). + """ + if context.data.get("asr_status") == "skipped_silence": + logger.info("Wake word armed but capture was silent; ignoring") + return PluginResult(success=False, message="", should_continue=False) + + command = ( + context.data.get("command") or context.data.get("transcript") or "" + ).strip() + if not command or not self.mcp_client: + return None # nothing we can do -> let the next handler try + + route = await router_client.classify(command) + if route.route != "home": + logger.info( + "Router: %r -> %s (P=%.2f); declining", + command, + route.route, + route.p_home, + ) + return None # not a home command -> pass to next handler (e.g. Hermes) + + async def conversation_fn(text: str) -> ConvResult: + d = await self.mcp_client.process_conversation(text) + return ConvResult( + response_type=d["response_type"], + code=d["code"], + speech=d["speech"], + success_count=d["success_count"], + ) + + async def llm_complete_fn(system: str, user: str) -> str: + llm = get_llm_client() + + def _call(): + # Don't set max_tokens (reasoning models reject it) and only set + # temperature when the model supports it — mirrors LLMClient.generate. + params = { + "model": llm.model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + } + if model_supports_temperature(llm.model): + params["temperature"] = 0.0 + resp = llm.client.chat.completions.create(**params) + return resp.choices[0].message.content or "" + + return await asyncio.to_thread(_call) + + res = await run_ha_cascade( + command, + conversation_fn=conversation_fn, + llm_complete_fn=llm_complete_fn, + ) + logger.info( + "HA cascade: %r -> %s (%.0fms) :: %s", + command, + res.final_tier, + res.total_ms, + res.message, + ) + if not res.success: + return None # HA couldn't act -> pass down the chain + + return PluginResult( + success=True, + message=res.message, + data={ + "final_tier": res.final_tier, + "p_home": route.p_home, + "traces": [ + (t.tier, round(t.latency_ms, 1), t.detail) for t in res.traces + ], + }, + should_continue=False, # handled -> stop the chain + ) + async def on_transcript(self, context: PluginContext) -> Optional[PluginResult]: """ Execute Home Assistant command from wake word transcript. @@ -140,7 +268,7 @@ async def on_transcript(self, context: PluginContext) -> Optional[PluginResult]: Context data: { 'command': 'turn off study lights', - 'original_transcript': 'vivi turn off study lights', + 'original_transcript': 'hermes turn off study lights', 'conversation_id': 'conv_123' } @@ -155,7 +283,9 @@ async def on_transcript(self, context: PluginContext) -> Optional[PluginResult]: command = context.data.get("command", "") if not command: - return PluginResult(success=False, message="No command provided", should_continue=True) + return PluginResult( + success=False, message="No command provided", should_continue=True + ) if not self.mcp_client: logger.error("MCP client not initialized") @@ -166,9 +296,21 @@ async def on_transcript(self, context: PluginContext) -> Optional[PluginResult]: ) try: + conversation_id = context.data.get("conversation_id") + + # Step 0: Extract just the HA command from mixed transcript + extracted = await self._extract_ha_command( + command, conversation_id=conversation_id + ) + if extracted: + logger.info(f"Extracted HA command: '{extracted}' (from: '{command}')") + command = extracted + # Step 1: Parse command using hybrid LLM + fallback parsing logger.info(f"Processing HA command: '{command}'") - parsed = await self._parse_command_hybrid(command) + parsed = await self._parse_command_hybrid( + command, conversation_id=conversation_id + ) if not parsed: return PluginResult( @@ -199,15 +341,25 @@ async def on_transcript(self, context: PluginContext) -> Optional[PluginResult]: service = service_map.get(parsed.action, "turn_on") # Step 4: Call Home Assistant service - logger.info(f"Calling {domain}.{service} for {len(entity_ids)} entities: {entity_ids}") + logger.info( + f"Calling {domain}.{service} for {len(entity_ids)} entities: {entity_ids}" + ) result = await self.mcp_client.call_service( - domain=domain, service=service, entity_ids=entity_ids, **parsed.parameters + domain=domain, + service=service, + entity_ids=entity_ids, + **parsed.parameters, ) # Step 5: Format user-friendly response entity_type_name = parsed.entity_type or domain - if parsed.target_type == "area": + if parsed.target_type == "all": + message = ( + f"I've {parsed.action.replace('_', ' ')} {len(entity_ids)} " + f"{entity_type_name}{'s' if len(entity_ids) != 1 else ''} everywhere" + ) + elif parsed.target_type == "area": message = ( f"I've {parsed.action.replace('_', ' ')} {len(entity_ids)} " f"{entity_type_name}{'s' if len(entity_ids) != 1 else ''} " @@ -274,8 +426,6 @@ async def on_plugin_action(self, context: PluginContext) -> Optional[PluginResul ) try: - from .command_parser import ParsedCommand - # Build a ParsedCommand from the action data target_type = context.data.get("target_type", "area") target = context.data.get("target", "") @@ -339,9 +489,6 @@ async def on_button_event(self, context: PluginContext) -> Optional[PluginResult using the button_actions config. Reuses the same entity resolution and service call logic as on_plugin_action(). """ - from .command_parser import ParsedCommand - from advanced_omi_backend.plugins.events import PluginEvent - # Map event to config key if context.event == PluginEvent.BUTTON_DOUBLE_PRESS: action_key = "double_press" @@ -369,7 +516,9 @@ async def on_button_event(self, context: PluginContext) -> Optional[PluginResult entity_type = action_config.get("entity_type", "light") if not target: - return PluginResult(success=False, message="No target in button_actions config") + return PluginResult( + success=False, message="No target in button_actions config" + ) parsed = ParsedCommand( action=service, @@ -419,8 +568,6 @@ async def on_button_event(self, context: PluginContext) -> Optional[PluginResult async def health_check(self) -> dict: """Ping Home Assistant API using the initialized client.""" - import time - if not self.mcp_client: return {"ok": False, "message": "MCP client not initialized"} @@ -430,7 +577,11 @@ async def health_check(self) -> dict: latency_ms = int((time.time() - start) * 1000) if str(result).strip() == "2": return {"ok": True, "message": "Connected", "latency_ms": latency_ms} - return {"ok": False, "message": f"Unexpected result: {result}", "latency_ms": latency_ms} + return { + "ok": False, + "message": f"Unexpected result: {result}", + "latency_ms": latency_ms, + } except Exception as e: return {"ok": False, "message": str(e)} @@ -492,8 +643,6 @@ async def _refresh_cache(self): logger.debug(f"Fetched {len(entity_details)} entity states") # Create cache - from datetime import datetime - self.entity_cache = EntityCache( areas=areas, area_entities=area_entities, @@ -503,14 +652,83 @@ async def _refresh_cache(self): ) logger.info( - f"Entity cache refreshed: {len(areas)} areas, " f"{len(entity_details)} entities" + f"Entity cache refreshed: {len(areas)} areas, " + f"{len(entity_details)} entities" ) except Exception as e: logger.error(f"Failed to refresh entity cache: {e}", exc_info=True) raise - async def _parse_command_with_llm(self, command: str) -> Optional["ParsedCommand"]: + async def _extract_ha_command( + self, transcript: str, *, conversation_id: Optional[str] = None + ) -> Optional[str]: + """ + Use a lightweight LLM call to extract only the Home Assistant command + from a transcript that may contain mixed conversation. + + When the keyword (e.g. "hermes") is detected anywhere in a long transcript, + the surrounding text often includes unrelated speech. This method asks the + LLM to return just the smart-home command portion. + + Args: + transcript: Transcript text with keyword already stripped. + conversation_id: Optional conversation ID for Langfuse session grouping. + + Returns: + Extracted command string, or None to fall back to the raw text. + """ + # Short transcripts are likely already just the command + if len(transcript.split()) <= 8: + return None + + try: + llm_client = get_llm_client() + + system_prompt = ( + "Extract ONLY the smart home / home assistant command from the " + "transcript below. The transcript is from a conversation and may " + "contain unrelated speech mixed in. Return ONLY the command text, " + "nothing else. If no smart home command is found, return NONE.\n\n" + "Examples:\n" + 'Input: "so anyway I was saying turn off the hall lights and then we went to dinner"\n' + "Output: turn off the hall lights\n\n" + 'Input: "turn on bedroom lights"\n' + "Output: turn on bedroom lights\n\n" + 'Input: "yeah the meeting was great oh and set living room brightness ' + 'to 50 percent and also the deadline is tomorrow"\n' + "Output: set living room brightness to 50 percent\n\n" + 'Input: "so I told him about the project and then toggle the kitchen fan ' + 'and after that we discussed lunch plans"\n' + "Output: toggle the kitchen fan" + ) + + response = llm_client.client.chat.completions.create( + model=llm_client.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": transcript}, + ], + temperature=0.0, + max_tokens=100, + ) + + result = response.choices[0].message.content.strip() + + if not result or result.upper() == "NONE": + logger.info("LLM extraction found no HA command in transcript") + return None + + logger.info(f"LLM extracted HA command: '{result}'") + return result + + except Exception as e: + logger.warning(f"LLM command extraction failed: {e}, using raw text") + return None + + async def _parse_command_with_llm( + self, command: str, *, conversation_id: Optional[str] = None + ) -> Optional["ParsedCommand"]: """ Parse command using LLM with structured system prompt. @@ -531,23 +749,40 @@ async def _parse_command_with_llm(self, command: str) -> Optional["ParsedCommand ) """ try: - from advanced_omi_backend.llm_client import get_llm_client - from advanced_omi_backend.prompt_registry import get_prompt_registry - - from .command_parser import ParsedCommand - llm_client = get_llm_client() registry = get_prompt_registry() - system_prompt = await registry.get_prompt("plugin.homeassistant.command_parser") + system_prompt = await registry.get_prompt( + "plugin.homeassistant.command_parser" + ) logger.debug(f"Parsing command with LLM: '{command}'") - # Use OpenAI chat format with system + user messages + # Build context from entity cache so the LLM knows valid targets + entity_context = "" + await self._ensure_cache_initialized() + if self.entity_cache: + areas = ( + ", ".join(self.entity_cache.areas) + if self.entity_cache.areas + else "none" + ) + labels = "" + if self.entity_cache.label_areas: + label_parts = [ + f"{lbl} (covers: {', '.join(areas_list)})" + for lbl, areas_list in self.entity_cache.label_areas.items() + ] + labels = "\nAvailable labels: " + ", ".join(label_parts) + entity_context = f"\n\nAvailable areas: {areas}{labels}\nUse target_type 'area' with an area/label name above, or target_type 'all' for everything." + response = llm_client.client.chat.completions.create( model=llm_client.model, messages=[ {"role": "system", "content": system_prompt}, - {"role": "user", "content": f'Command: "{command}"\n\nReturn JSON only.'}, + { + "role": "user", + "content": f'Command: "{command}"{entity_context}\n\nReturn JSON only.', + }, ], temperature=0.1, max_tokens=150, @@ -588,7 +823,9 @@ async def _parse_command_with_llm(self, command: str) -> Optional["ParsedCommand return parsed except json.JSONDecodeError as e: - logger.error(f"Failed to parse LLM JSON response: {e}\nResponse: {result_text}") + logger.error( + f"Failed to parse LLM JSON response: {e}\nResponse: {result_text}" + ) return None except Exception as e: logger.error(f"LLM command parsing failed: {e}", exc_info=True) @@ -616,8 +853,6 @@ async def _resolve_entities(self, parsed: "ParsedCommand") -> List[str]: ... )) ["light.tubelight_3"] """ - from .command_parser import ParsedCommand - # Ensure cache is ready await self._ensure_cache_initialized() @@ -631,8 +866,12 @@ async def _resolve_entities(self, parsed: "ParsedCommand") -> List[str]: ) if not entities: - entity_desc = f"{parsed.entity_type}s" if parsed.entity_type else "entities" - available = list(self.entity_cache.areas) + list(self.entity_cache.label_areas.keys()) + entity_desc = ( + f"{parsed.entity_type}s" if parsed.entity_type else "entities" + ) + available = list(self.entity_cache.areas) + list( + self.entity_cache.label_areas.keys() + ) raise ValueError( f"No {entity_desc} found in area/label '{parsed.target}'. " f"Available: {', '.join(available)}" @@ -644,9 +883,33 @@ async def _resolve_entities(self, parsed: "ParsedCommand") -> List[str]: ) return entities + elif parsed.target_type == "all": + # Get entities across ALL areas, optionally filtered by type + entities = [] + for area in self.entity_cache.areas: + entities.extend( + self.entity_cache.get_entities_in_area( + area=area, entity_type=parsed.entity_type + ) + ) + + if not entities: + entity_desc = ( + f"{parsed.entity_type}s" if parsed.entity_type else "entities" + ) + raise ValueError(f"No {entity_desc} found in any area") + + logger.info( + f"Resolved 'all' to {len(entities)} " + f"{parsed.entity_type or 'entity'}(s) across {len(self.entity_cache.areas)} areas" + ) + return entities + elif parsed.target_type == "all_in_area": # Get ALL entities in area (no filter) - entities = self.entity_cache.get_entities_in_area(area=parsed.target, entity_type=None) + entities = self.entity_cache.get_entities_in_area( + area=parsed.target, entity_type=None + ) if not entities: raise ValueError( @@ -654,7 +917,9 @@ async def _resolve_entities(self, parsed: "ParsedCommand") -> List[str]: f"Available areas: {', '.join(self.entity_cache.areas)}" ) - logger.info(f"Resolved 'all in {parsed.target}' to {len(entities)} entities") + logger.info( + f"Resolved 'all in {parsed.target}' to {len(entities)} entities" + ) return entities elif parsed.target_type == "entity": @@ -726,7 +991,9 @@ async def _parse_command_fallback(self, command: str) -> Optional[Dict[str, Any] "action_desc": action_desc, } - async def _parse_command_hybrid(self, command: str) -> Optional["ParsedCommand"]: + async def _parse_command_hybrid( + self, command: str, *, conversation_id: Optional[str] = None + ) -> Optional["ParsedCommand"]: """ Hybrid command parser: Try LLM first, fallback to keywords. @@ -736,6 +1003,7 @@ async def _parse_command_hybrid(self, command: str) -> Optional["ParsedCommand"] Args: command: Natural language command + conversation_id: Optional conversation ID for Langfuse session grouping. Returns: ParsedCommand if successful, None otherwise @@ -744,14 +1012,13 @@ async def _parse_command_hybrid(self, command: str) -> Optional["ParsedCommand"] >>> await self._parse_command_hybrid("turn off study lights") ParsedCommand(action="turn_off", target_type="area", target="study", ...) """ - import asyncio - - from .command_parser import ParsedCommand - # Try LLM parsing with timeout try: logger.debug("Attempting LLM-based command parsing...") - parsed = await asyncio.wait_for(self._parse_command_with_llm(command), timeout=5.0) + parsed = await asyncio.wait_for( + self._parse_command_with_llm(command, conversation_id=conversation_id), + timeout=5.0, + ) if parsed: logger.info("LLM parsing succeeded") @@ -818,12 +1085,12 @@ async def test_connection(config: Dict[str, Any]) -> Dict[str, Any]: >>> result['success'] True """ - import time - try: # Validate required config fields required_fields = ["ha_url", "ha_token"] - missing_fields = [field for field in required_fields if not config.get(field)] + missing_fields = [ + field for field in required_fields if not config.get(field) + ] if missing_fields: return { diff --git a/plugins/hourly_recap/__init__.py b/plugins/hourly_recap/__init__.py index d60fa58f..dd22c433 100644 --- a/plugins/hourly_recap/__init__.py +++ b/plugins/hourly_recap/__init__.py @@ -6,4 +6,4 @@ from .plugin import HourlyRecapPlugin -__all__ = ['HourlyRecapPlugin'] +__all__ = ["HourlyRecapPlugin"] diff --git a/plugins/hourly_recap/plugin.py b/plugins/hourly_recap/plugin.py index 69ab4931..7a3da70c 100644 --- a/plugins/hourly_recap/plugin.py +++ b/plugins/hourly_recap/plugin.py @@ -4,18 +4,23 @@ On OMI device double-click, queries the last hour's conversations, generates a consolidated LLM recap, and emails it to the user. """ + import html import logging +import time from datetime import datetime, timedelta from typing import Any, Dict, List, Optional +from bson import ObjectId +from email_summarizer.email_service import SMTPEmailService + from advanced_omi_backend.database import get_database from advanced_omi_backend.llm_client import async_generate from advanced_omi_backend.models.conversation import Conversation from advanced_omi_backend.plugins.base import BasePlugin, PluginContext, PluginResult from advanced_omi_backend.plugins.events import PluginEvent +from advanced_omi_backend.prompt_registry import get_prompt_registry from advanced_omi_backend.utils.logging_utils import mask_dict -from email_summarizer.email_service import SMTPEmailService logger = logging.getLogger(__name__) @@ -114,8 +119,6 @@ async def initialize(self): async def health_check(self) -> dict: """Test SMTP connectivity using the initialized email service.""" - import time - if not self.email_service: return {"ok": False, "message": "Email service not initialized"} @@ -124,8 +127,16 @@ async def health_check(self) -> dict: success = await self.email_service.test_connection() latency_ms = int((time.time() - start) * 1000) if success: - return {"ok": True, "message": "SMTP connected", "latency_ms": latency_ms} - return {"ok": False, "message": "SMTP connection failed", "latency_ms": latency_ms} + return { + "ok": True, + "message": "SMTP connected", + "latency_ms": latency_ms, + } + return { + "ok": False, + "message": "SMTP connection failed", + "latency_ms": latency_ms, + } except Exception as e: return {"ok": False, "message": str(e)} @@ -257,8 +268,6 @@ def _build_conversations_block(self, conversations: List[Conversation]) -> str: async def _generate_recap(self, conversations_block: str) -> str: """Generate consolidated recap via LLM.""" try: - from advanced_omi_backend.prompt_registry import get_prompt_registry - registry = get_prompt_registry() instruction = await registry.get_prompt( "plugin.hourly_recap.summary", @@ -283,8 +292,6 @@ async def _generate_recap(self, conversations_block: str) -> str: async def _get_user_email(self, user_id: str) -> Optional[str]: """Get notification email for a user.""" try: - from bson import ObjectId - user = await self.db["users"].find_one({"_id": ObjectId(user_id)}) if not user: logger.warning(f"User {user_id} not found") @@ -306,9 +313,7 @@ def _format_subject(self) -> str: now = datetime.utcnow().strftime("%b %d, %Y at %I:%M %p") return f"{self.subject_prefix} - {now}" - def _format_html( - self, recap: str, conversations: List[Conversation] - ) -> str: + def _format_html(self, recap: str, conversations: List[Conversation]) -> str: """Format HTML email body.""" now_str = datetime.utcnow().strftime("%B %d, %Y at %I:%M %p") recap_escaped = html.escape(recap, quote=True).replace("\n", "
") @@ -317,9 +322,7 @@ def _format_html( conv_items = "" for conv in conversations: title = html.escape(conv.title or "Untitled", quote=True) - created = ( - conv.created_at.strftime("%I:%M %p") if conv.created_at else "N/A" - ) + created = conv.created_at.strftime("%I:%M %p") if conv.created_at else "N/A" duration = ( f"{int(conv.audio_total_duration // 60)}m {int(conv.audio_total_duration % 60)}s" if conv.audio_total_duration @@ -418,18 +421,14 @@ def _format_html( """ - def _format_text( - self, recap: str, conversations: List[Conversation] - ) -> str: + def _format_text(self, recap: str, conversations: List[Conversation]) -> str: """Format plain text email body.""" now_str = datetime.utcnow().strftime("%B %d, %Y at %I:%M %p") conv_lines = [] for i, conv in enumerate(conversations, 1): title = conv.title or "Untitled" - created = ( - conv.created_at.strftime("%I:%M %p") if conv.created_at else "N/A" - ) + created = conv.created_at.strftime("%I:%M %p") if conv.created_at else "N/A" duration = ( f"{int(conv.audio_total_duration // 60)}m {int(conv.audio_total_duration % 60)}s" if conv.audio_total_duration @@ -468,10 +467,13 @@ def _format_text( @staticmethod async def test_connection(config: Dict[str, Any]) -> Dict[str, Any]: """Test SMTP connection with provided configuration.""" - import time - try: - required_fields = ["smtp_host", "smtp_username", "smtp_password", "from_email"] + required_fields = [ + "smtp_host", + "smtp_username", + "smtp_password", + "from_email", + ] missing_fields = [f for f in required_fields if not config.get(f)] if missing_fields: diff --git a/plugins/test_button_actions/__init__.py b/plugins/test_button_actions/__init__.py deleted file mode 100644 index 947efccb..00000000 --- a/plugins/test_button_actions/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Test Button Actions plugin for Chronicle. - -Maps device button events to configurable actions like closing conversations -or triggering cross-plugin calls. -""" - -from .plugin import TestButtonActionsPlugin - -__all__ = ['TestButtonActionsPlugin'] diff --git a/plugins/test_button_actions/config.yml b/plugins/test_button_actions/config.yml deleted file mode 100644 index 1bf3b590..00000000 --- a/plugins/test_button_actions/config.yml +++ /dev/null @@ -1,12 +0,0 @@ -actions: - single_press: - type: close_conversation - double_press: - type: call_plugin - plugin_id: homeassistant - action: call_service - data: - target_type: area - target: hall - entity_type: light - service: turn_off diff --git a/plugins/test_event/__init__.py b/plugins/test_event/__init__.py index 5f3f2ecf..837873f4 100644 --- a/plugins/test_event/__init__.py +++ b/plugins/test_event/__init__.py @@ -2,4 +2,4 @@ from .plugin import TestEventPlugin -__all__ = ['TestEventPlugin'] +__all__ = ["TestEventPlugin"] diff --git a/plugins/test_event/event_storage.py b/plugins/test_event/event_storage.py index 4fb618f9..cdb6d95f 100644 --- a/plugins/test_event/event_storage.py +++ b/plugins/test_event/event_storage.py @@ -3,6 +3,7 @@ Provides async SQLite operations for logging and querying plugin events. """ + import json import logging import os @@ -34,7 +35,9 @@ async def initialize(self): try: db_dir.mkdir(parents=True, exist_ok=True) logger.info(f"🔍 DEBUG: Directory created/verified: {db_dir}") - logger.info(f"🔍 DEBUG: Directory permissions: {oct(db_dir.stat().st_mode)}") + logger.info( + f"🔍 DEBUG: Directory permissions: {oct(db_dir.stat().st_mode)}" + ) except Exception as e: logger.error(f"🔍 DEBUG: Failed to create directory: {e}") raise @@ -67,13 +70,18 @@ async def initialize(self): except Exception as e: logger.error(f"🔍 DEBUG: Failed to connect to database: {e}") - logger.error(f"🔍 DEBUG: Database file exists: {Path(self.db_path).exists()}") + logger.error( + f"🔍 DEBUG: Database file exists: {Path(self.db_path).exists()}" + ) if Path(self.db_path).exists(): - logger.error(f"🔍 DEBUG: Database file permissions: {oct(Path(self.db_path).stat().st_mode)}") + logger.error( + f"🔍 DEBUG: Database file permissions: {oct(Path(self.db_path).stat().st_mode)}" + ) raise # Create events table - await self.db.execute(""" + await self.db.execute( + """ CREATE TABLE IF NOT EXISTS plugin_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME NOT NULL, @@ -83,18 +91,23 @@ async def initialize(self): metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) - """) + """ + ) # Create index for faster queries - await self.db.execute(""" + await self.db.execute( + """ CREATE INDEX IF NOT EXISTS idx_event_type ON plugin_events(event) - """) + """ + ) - await self.db.execute(""" + await self.db.execute( + """ CREATE INDEX IF NOT EXISTS idx_user_id ON plugin_events(user_id) - """) + """ + ) await self.db.commit() logger.info(f"Event storage initialized at {self.db_path}") @@ -104,7 +117,7 @@ async def log_event( event: str, user_id: str, data: Dict[str, Any], - metadata: Optional[Dict[str, Any]] = None + metadata: Optional[Dict[str, Any]] = None, ) -> int: """ Log an event to the database. @@ -135,7 +148,7 @@ async def log_event( except Exception as e: logger.error( f"💾 STORAGE: JSON serialization failed for event '{event}': {e}", - exc_info=True + exc_info=True, ) raise @@ -148,7 +161,7 @@ async def log_event( INSERT INTO plugin_events (timestamp, event, user_id, data, metadata) VALUES (?, ?, ?, ?, ?) """, - (timestamp, event, user_id, data_json, metadata_json) + (timestamp, event, user_id, data_json, metadata_json), ) await self.db.commit() @@ -164,7 +177,7 @@ async def log_event( except Exception as e: logger.error( f"💾 STORAGE: Database operation failed for event '{event}': {e}", - exc_info=True + exc_info=True, ) raise @@ -188,7 +201,7 @@ async def get_events_by_type(self, event: str) -> List[Dict[str, Any]]: WHERE event = ? ORDER BY created_at DESC """, - (event,) + (event,), ) rows = await cursor.fetchall() @@ -214,7 +227,7 @@ async def get_events_by_user(self, user_id: str) -> List[Dict[str, Any]]: WHERE user_id = ? ORDER BY created_at DESC """, - (user_id,) + (user_id,), ) rows = await cursor.fetchall() @@ -274,13 +287,10 @@ async def get_event_count(self, event: Optional[str] = None) -> int: if event: cursor = await self.db.execute( - "SELECT COUNT(*) FROM plugin_events WHERE event = ?", - (event,) + "SELECT COUNT(*) FROM plugin_events WHERE event = ?", (event,) ) else: - cursor = await self.db.execute( - "SELECT COUNT(*) FROM plugin_events" - ) + cursor = await self.db.execute("SELECT COUNT(*) FROM plugin_events") row = await cursor.fetchone() return row[0] if row else 0 @@ -299,18 +309,18 @@ def _rows_to_dicts(self, rows: List[tuple]) -> List[Dict[str, Any]]: for row in rows: event_dict = { - 'id': row[0], - 'timestamp': row[1], - 'event': row[2], - 'user_id': row[3], - 'data': json.loads(row[4]) if row[4] else {}, - 'metadata': json.loads(row[5]) if row[5] else {}, - 'created_at': row[6] + "id": row[0], + "timestamp": row[1], + "event": row[2], + "user_id": row[3], + "data": json.loads(row[4]) if row[4] else {}, + "metadata": json.loads(row[5]) if row[5] else {}, + "created_at": row[6], } # Flatten data fields to top level for easier access in tests - if isinstance(event_dict['data'], dict): - event_dict.update(event_dict['data']) + if isinstance(event_dict["data"], dict): + event_dict.update(event_dict["data"]) events.append(event_dict) diff --git a/plugins/test_event/plugin.py b/plugins/test_event/plugin.py index d75a88df..40bf1215 100644 --- a/plugins/test_event/plugin.py +++ b/plugins/test_event/plugin.py @@ -4,6 +4,7 @@ Logs all plugin events to SQLite database for integration testing. Subscribes to all event types to verify event dispatch system works correctly. """ + import logging from typing import Any, Dict, List, Optional @@ -27,12 +28,12 @@ class TestEventPlugin(BasePlugin): All events are logged to SQLite database with full context for test verification. """ - SUPPORTED_ACCESS_LEVELS: List[str] = ['transcript', 'conversation', 'memory'] + SUPPORTED_ACCESS_LEVELS: List[str] = ["transcript", "conversation", "memory"] def __init__(self, config: Dict[str, Any]): super().__init__(config) self.storage = EventStorage( - db_path=config.get('db_path', '/app/debug/test_plugin_events.db') + db_path=config.get("db_path", "/app/debug/test_plugin_events.db") ) self.event_count = 0 @@ -66,15 +67,15 @@ async def on_transcript(self, context: PluginContext) -> Optional[PluginResult]: event_type = context.event # 'transcript.streaming' or 'transcript.batch' # Extract key data fields - transcript = context.data.get('transcript', '') - conversation_id = context.data.get('conversation_id', 'unknown') + transcript = context.data.get("transcript", "") + conversation_id = context.data.get("conversation_id", "unknown") # Log to storage row_id = await self.storage.log_event( event=event_type, user_id=context.user_id, data=context.data, - metadata=context.metadata + metadata=context.metadata, ) self.event_count += 1 @@ -89,7 +90,7 @@ async def on_transcript(self, context: PluginContext) -> Optional[PluginResult]: return PluginResult( success=True, message=f"Transcript event logged (row_id={row_id})", - should_continue=True # Don't block normal processing + should_continue=True, # Don't block normal processing ) except Exception as e: @@ -97,10 +98,12 @@ async def on_transcript(self, context: PluginContext) -> Optional[PluginResult]: return PluginResult( success=False, message=f"Failed to log transcript event: {e}", - should_continue=True + should_continue=True, ) - async def on_conversation_complete(self, context: PluginContext) -> Optional[PluginResult]: + async def on_conversation_complete( + self, context: PluginContext + ) -> Optional[PluginResult]: """ Log conversation completion events. @@ -116,8 +119,8 @@ async def on_conversation_complete(self, context: PluginContext) -> Optional[Plu Returns: PluginResult indicating success """ - conversation_id = context.data.get('conversation_id', 'unknown') - duration = context.data.get('duration', 0) + conversation_id = context.data.get("conversation_id", "unknown") + duration = context.data.get("duration", 0) # Add at start logger.info( @@ -135,7 +138,7 @@ async def on_conversation_complete(self, context: PluginContext) -> Optional[Plu event=context.event, # 'conversation.complete' user_id=context.user_id, data=context.data, - metadata=context.metadata + metadata=context.metadata, ) # Add after storage @@ -153,8 +156,7 @@ async def on_conversation_complete(self, context: PluginContext) -> Optional[Plu except Exception as e: # Enhance error logging logger.error( - f" ✗ Storage FAILED for {conversation_id[:12]}: {e}", - exc_info=True + f" ✗ Storage FAILED for {conversation_id[:12]}: {e}", exc_info=True ) return PluginResult( success=False, @@ -162,7 +164,9 @@ async def on_conversation_complete(self, context: PluginContext) -> Optional[Plu should_continue=True, ) - async def on_memory_processed(self, context: PluginContext) -> Optional[PluginResult]: + async def on_memory_processed( + self, context: PluginContext + ) -> Optional[PluginResult]: """ Log memory processing events. @@ -183,17 +187,17 @@ async def on_memory_processed(self, context: PluginContext) -> Optional[PluginRe PluginResult indicating success """ try: - conversation_id = context.data.get('conversation_id', 'unknown') - memory_count = context.data.get('memory_count', 0) - memory_provider = context.metadata.get('memory_provider', 'unknown') - processing_time = context.metadata.get('processing_time', 0) + conversation_id = context.data.get("conversation_id", "unknown") + memory_count = context.data.get("memory_count", 0) + memory_provider = context.metadata.get("memory_provider", "unknown") + processing_time = context.metadata.get("processing_time", 0) # Log to storage row_id = await self.storage.log_event( event=context.event, # 'memory.processed' user_id=context.user_id, data=context.data, - metadata=context.metadata + metadata=context.metadata, ) self.event_count += 1 @@ -210,7 +214,7 @@ async def on_memory_processed(self, context: PluginContext) -> Optional[PluginRe return PluginResult( success=True, message=f"Memory event logged (row_id={row_id})", - should_continue=True + should_continue=True, ) except Exception as e: @@ -218,7 +222,7 @@ async def on_memory_processed(self, context: PluginContext) -> Optional[PluginRe return PluginResult( success=False, message=f"Failed to log memory event: {e}", - should_continue=True + should_continue=True, ) async def cleanup(self): diff --git a/quickstart.md b/quickstart.md index ac54e75a..2dba06a0 100644 --- a/quickstart.md +++ b/quickstart.md @@ -16,7 +16,7 @@ Think of it like having Siri/Alexa, but it's **your own AI** running on **your h - **Chronicle Backend** - The main AI brain (transcription, memory, processing) - **Tailscale** - Creates secure tunnel so your phone can reach home -### On Your Phone +### On Your Phone - **Tailscale** - Connects securely to your home computer - **Chronicle Mobile App** - Interface for your OMI device and conversations @@ -40,7 +40,7 @@ Think of it like having Siri/Alexa, but it's **your own AI** running on **your h ### On Your Home Computer **Git** (Downloads code from the internet): -- **Windows/Mac**: [Download Git](https://git-scm.com/downloads) +- **Windows/Mac**: [Download Git](https://git-scm.com/downloads) - **Linux**: `sudo apt install git` or `sudo yum install git` **Docker** (Runs the AI services): @@ -184,7 +184,7 @@ uv run --with-requirements setup-requirements.txt python services.py start --all Before connecting your phone, make sure everything works: 1. Visit: **https://[your-tailscale-ip]** (like `https://100.64.1.5`) - + *Your browser will warn about "unsafe certificate" - click "Advanced" → "Proceed anyway"* 2. You should see the Chronicle dashboard @@ -204,27 +204,27 @@ Before connecting your phone, make sure everything works: - Enable "Install from unknown sources" in Android settings - Tap the downloaded APK file to install -### iPhone Users +### iPhone Users 1. Go to [GitHub Releases](https://github.com/AnkushMalaker/chronicle/releases) -2. Find the latest release and download `chronicle-ios.ipa` +2. Find the latest release and download `chronicle-ios.ipa` 3. Install using sideloading tool: - **AltStore** (recommended): [altstore.io](https://altstore.io) - **Sideloadly**: [sideloadly.io](https://sideloadly.io) - + *Note: iOS requires sideloading since we're not on App Store yet* ### Configure the App 1. **First**: Make sure Tailscale is running on your phone 2. Open Chronicle app 3. Go to Settings → Backend Configuration -4. Enter Backend URL: `https://[your-tailscale-ip]` - +4. Enter Backend URL: `https://[your-tailscale-ip]` + *Use the same IP as your web dashboard - like `https://100.64.1.5`* - + 5. Tap "Test Connection" - should show **green checkmark** 6. If connection fails, double-check: - Tailscale is running on phone - - Same IP as web dashboard + - Same IP as web dashboard - Using `https://` (not `http://`) ## Step 6: Connect Your OMI Device @@ -240,7 +240,7 @@ Before connecting your phone, make sure everything works: **What you now have:** - ✅ Personal AI running on your home computer -- ✅ Phone app connected securely via Tailscale +- ✅ Phone app connected securely via Tailscale - ✅ OMI device streaming audio to your AI - ✅ All conversations processed privately and stored locally - ✅ Access from anywhere via your phone @@ -257,7 +257,7 @@ Before connecting your phone, make sure everything works: - **"Permission denied"**: Try `sudo` before commands (Linux/Mac) - **"uv not found"**: Restart terminal after installing uv -### Connection Issues +### Connection Issues - **Phone can't reach backend**: Check Tailscale is running on both devices - **Certificate warnings**: Click "Advanced" → "Proceed" in browser - **Test connection fails**: Verify you're using `https://` and correct Tailscale IP @@ -287,6 +287,6 @@ Before connecting your phone, make sure everything works: ## Need Help? -- **Full Documentation**: [CLAUDE.md](CLAUDE.md) - Complete technical reference -- **Architecture Details**: [Docs/overview.md](Docs/overview.md) - How everything works -- **Advanced Setup**: [Docs/init-system.md](Docs/init-system.md) - Power user options +- **Full Documentation**: [AGENTS.md](AGENTS.md) - Complete technical reference +- **Architecture Details**: [docs/overview.md](docs/overview.md) - How everything works +- **Advanced Setup**: [docs/init-system.md](docs/init-system.md) - Power user options diff --git a/restart.sh b/restart.sh index 019518c4..9ac7c54d 100755 --- a/restart.sh +++ b/restart.sh @@ -1,2 +1,3 @@ #!/bin/bash +source "$(dirname "$0")/scripts/check_uv.sh" uv run --with-requirements setup-requirements.txt python services.py restart --all diff --git a/scripts/check_uv.sh b/scripts/check_uv.sh new file mode 100755 index 00000000..8325bfa3 --- /dev/null +++ b/scripts/check_uv.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Shared prerequisite check for Chronicle scripts. +# Source this at the top of any shell script that needs uv. + +if ! command -v uv &> /dev/null; then + echo "❌ 'uv' is not installed." + echo "" + echo "Chronicle requires 'uv' (Python package manager) to run." + echo "Install it with:" + echo "" + echo " curl -LsSf https://astral.sh/uv/install.sh | sh" + echo "" + echo "Then restart your terminal and try again." + exit 1 +fi diff --git a/scripts/generate-docker-configs.py b/scripts/generate-docker-configs.py index 2dea26be..92906181 100755 --- a/scripts/generate-docker-configs.py +++ b/scripts/generate-docker-configs.py @@ -8,55 +8,58 @@ from pathlib import Path # Add lib directory to path -sys.path.append(str(Path(__file__).parent / 'lib')) +sys.path.append(str(Path(__file__).parent / "lib")) + +from env_utils import format_variable, get_config_env_variables, get_skaffold_variables -from env_utils import get_config_env_variables, get_skaffold_variables, format_variable def generate_env_file(service_name: str, output_path: str): """Generate a .env file for a service with only config.env variables.""" output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) - + # Get list of variables defined in config.env config_variables = get_config_env_variables() - + # For skaffold, only include variables actually used by Skaffold templates - if service_name == 'skaffold': + if service_name == "skaffold": skaffold_needed_vars = get_skaffold_variables() config_variables = config_variables.intersection(skaffold_needed_vars) - - with open(output_path, 'w') as f: + + with open(output_path, "w") as f: f.write("# Auto-generated - DO NOT EDIT DIRECTLY\n") f.write("# Edit config.env and run 'make config' to regenerate\n") - if service_name == 'skaffold': + if service_name == "skaffold": f.write("# This file contains ONLY variables used by Skaffold templates\n") f.write("\n") - + # Only include variables that are defined in config.env for var_name in sorted(config_variables): var_value = os.environ.get(var_name) if var_value: # Only include if variable has a value f.write(f"{format_variable(var_name, var_value, service_name)}\n") + def main(): """Generate Docker Compose configuration files.""" - + # Define output files outputs = { - 'advanced-backend': 'backends/advanced/.env', - 'speaker-recognition': 'extras/speaker-recognition/.env', - 'openmemory-mcp': 'extras/openmemory-mcp/.env', - 'asr-services': 'extras/asr-services/.env', - 'havpe-relay': 'extras/havpe-relay/.env', - 'simple-backend': 'backends/simple/.env', - 'omi-webhook-compatible': 'backends/other-backends/omi-webhook-compatible/.env', - 'skaffold': 'skaffold.env', + "advanced-backend": "backends/advanced/.env", + "speaker-recognition": "extras/speaker-recognition/.env", + "openmemory-mcp": "extras/openmemory-mcp/.env", + "asr-services": "extras/asr-services/.env", + "havpe-relay": "extras/havpe-relay/.env", + "simple-backend": "backends/simple/.env", + "omi-webhook-compatible": "backends/other-backends/omi-webhook-compatible/.env", + "skaffold": "skaffold.env", } - + for service_name, output_path in outputs.items(): print(f"Generating {service_name} configuration...") generate_env_file(service_name, output_path) print(f"✅ {service_name} configuration generated") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/generate-k8s-configs.py b/scripts/generate-k8s-configs.py index 2eea45aa..d2059899 100755 --- a/scripts/generate-k8s-configs.py +++ b/scripts/generate-k8s-configs.py @@ -8,25 +8,26 @@ from pathlib import Path # Add lib directory to path -sys.path.append(str(Path(__file__).parent / 'lib')) +sys.path.append(str(Path(__file__).parent / "lib")) + +from env_utils import classify_secrets, get_resolved_env_vars -from env_utils import get_resolved_env_vars, classify_secrets def generate_k8s_manifests(namespace: str = "chronicle"): """Generate Kubernetes ConfigMap and Secret manifests""" print(f"Generating Kubernetes ConfigMap and Secret for namespace {namespace}...") - + # Create output directory output_dir = Path("k8s-manifests") output_dir.mkdir(exist_ok=True) - + # Get all resolved environment variables all_vars = get_resolved_env_vars() config_vars, secret_vars = classify_secrets(all_vars) - + # Generate ConfigMap configmap_path = output_dir / "configmap.yaml" - with open(configmap_path, 'w') as f: + with open(configmap_path, "w") as f: f.write("apiVersion: v1\n") f.write("kind: ConfigMap\n") f.write("metadata:\n") @@ -36,16 +37,16 @@ def generate_k8s_manifests(namespace: str = "chronicle"): f.write(" app.kubernetes.io/name: chronicle\n") f.write(" app.kubernetes.io/component: config\n") f.write("data:\n") - + for var_name in sorted(config_vars.keys()): var_value = config_vars[var_name] # Escape quotes in values escaped_value = var_value.replace('"', '\\"') f.write(f' {var_name}: "{escaped_value}"\n') - + # Generate Secret secret_path = output_dir / "secrets.yaml" - with open(secret_path, 'w') as f: + with open(secret_path, "w") as f: f.write("apiVersion: v1\n") f.write("kind: Secret\n") f.write("type: Opaque\n") @@ -56,14 +57,15 @@ def generate_k8s_manifests(namespace: str = "chronicle"): f.write(" app.kubernetes.io/name: chronicle\n") f.write(" app.kubernetes.io/component: secrets\n") f.write("data:\n") - + import base64 + for var_name in sorted(secret_vars.keys()): var_value = secret_vars[var_name] # Base64 encode the value encoded_value = base64.b64encode(var_value.encode()).decode() f.write(f" {var_name}: {encoded_value}\n") - + print("Generated:") print(f" - {configmap_path}") print(f" - {secret_path}") @@ -72,10 +74,12 @@ def generate_k8s_manifests(namespace: str = "chronicle"): print(f" kubectl apply -f {configmap_path}") print(f" kubectl apply -f {secret_path}") + def main(): """Main entry point""" namespace = sys.argv[1] if len(sys.argv) > 1 else "chronicle" generate_k8s_manifests(namespace) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/k8s/analyze-disk-usage.sh b/scripts/k8s/analyze-disk-usage.sh index 582be55d..e7243e05 100755 --- a/scripts/k8s/analyze-disk-usage.sh +++ b/scripts/k8s/analyze-disk-usage.sh @@ -33,7 +33,7 @@ echo "💾 Analyzing disk space usage on $NODE_NAME..." run_ssh_command() { local cmd="$1" local use_sudo="${2:-false}" - + if [[ -n "$SUDO_PASSWORD" && "$use_sudo" == "true" ]]; then echo "$SUDO_PASSWORD" | ssh "$NODE_NAME" "sudo -S $cmd" 2>/dev/null elif [[ "$use_sudo" == "true" ]]; then @@ -107,4 +107,4 @@ echo " - Docker containers: docker container prune" echo " - Docker volumes: docker volume prune" echo " - MicroK8s images: microk8s ctr images rm " echo " - Log files: journalctl --vacuum-time=7d" -echo " - Snap packages: snap remove --purge " \ No newline at end of file +echo " - Snap packages: snap remove --purge " diff --git a/scripts/k8s/cleanup-registry-storage.sh b/scripts/k8s/cleanup-registry-storage.sh index 7f247e45..add83e62 100755 --- a/scripts/k8s/cleanup-registry-storage.sh +++ b/scripts/k8s/cleanup-registry-storage.sh @@ -69,11 +69,11 @@ case $choice in 1) echo "🧹 Attempting registry garbage collection..." echo "Looking for registry garbage collection methods..." - + # Try to find registry processes or containers echo "Registry processes:" ssh "$NODE_NAME" "ps aux | grep registry || echo 'No registry processes found'" - + echo "" echo "💡 Manual cleanup needed:" echo "1. Find the registry container/pod:" @@ -81,7 +81,7 @@ case $choice in echo "2. Run garbage collection in the registry:" echo " kubectl exec -it -- registry garbage-collect /etc/docker/registry/config.yml" ;; - + 2) echo "📦 Repository cleanup..." run_ssh_sudo "ls -la '$REGISTRY_PVC/docker/registry/v2/repositories/'" @@ -89,13 +89,13 @@ case $choice in echo "💡 To delete specific repositories:" echo " sudo rm -rf '$REGISTRY_PVC/docker/registry/v2/repositories/'" ;; - + 3) echo "☢️ NUCLEAR OPTION - This will delete ALL registry data!" echo "You will lose all your container images in the registry!" echo "" read -p "Are you ABSOLUTELY sure? Type 'DELETE_ALL_REGISTRY_DATA': " confirm - + if [[ "$confirm" == "DELETE_ALL_REGISTRY_DATA" ]]; then echo "💣 Deleting all registry data..." run_ssh_sudo "rm -rf '$REGISTRY_PVC'/*" @@ -106,7 +106,7 @@ case $choice in echo "❌ Cancelled - incorrect confirmation" fi ;; - + *) echo "👋 Exiting without changes" ;; @@ -114,4 +114,4 @@ esac echo "" echo "💾 Final disk usage:" -ssh "$NODE_NAME" "df -h / | tail -1" \ No newline at end of file +ssh "$NODE_NAME" "df -h / | tail -1" diff --git a/scripts/k8s/configure-insecure-registry-remote.sh b/scripts/k8s/configure-insecure-registry-remote.sh index 34809489..329b00fa 100644 --- a/scripts/k8s/configure-insecure-registry-remote.sh +++ b/scripts/k8s/configure-insecure-registry-remote.sh @@ -45,14 +45,14 @@ echo "SSH user: $SSH_USER" configure_node() { local node=$1 local user=$2 - + echo "📋 Configuring node: $node" - + # Create the containerd configuration directory and file ssh -o StrictHostKeyChecking=no "$user@$node" " echo 'Creating MicroK8s containerd configuration...' sudo mkdir -p /var/snap/microk8s/current/args/certs.d/$REGISTRY - + echo 'Creating hosts.toml configuration...' sudo tee /var/snap/microk8s/current/args/certs.d/$REGISTRY/hosts.toml > /dev/null < /dev/null && echo '✅ Registry accessible via HTTP' || echo '❌ Registry not accessible' - + echo 'Configuration complete on $node' " } diff --git a/scripts/k8s/list-registry-images.sh b/scripts/k8s/list-registry-images.sh index 67b8b5f1..ea4bbf24 100755 --- a/scripts/k8s/list-registry-images.sh +++ b/scripts/k8s/list-registry-images.sh @@ -22,7 +22,7 @@ fi for repo in $REPOS; do echo "🏷️ Repository: $repo" tags=$(curl -s http://$REGISTRY/v2/$repo/tags/list | jq -r '.tags[]?' 2>/dev/null) - + if [ -z "$tags" ]; then echo " No tags found" else @@ -32,4 +32,3 @@ for repo in $REPOS; do fi echo done - diff --git a/scripts/k8s/load-env.sh b/scripts/k8s/load-env.sh index a9ab113f..28db5df7 100644 --- a/scripts/k8s/load-env.sh +++ b/scripts/k8s/load-env.sh @@ -26,7 +26,7 @@ load_config_env() { set -a # automatically export all variables source "$CONFIG_ENV" set +a # stop automatically exporting - + # Export commonly used variables for k8s scripts export SPEAKER_NODE="${SPEAKER_NODE:-}" export CONTAINER_REGISTRY="${CONTAINER_REGISTRY:-localhost:32000}" @@ -39,13 +39,13 @@ load_config_env() { get_config_var() { local var_name="$1" local default_value="${2:-}" - + # Load config if not already loaded if [ -z "${CONFIG_LOADED:-}" ]; then load_config_env export CONFIG_LOADED=1 fi - + # Return the variable value or default eval "echo \${$var_name:-$default_value}" } @@ -53,16 +53,16 @@ get_config_var() { # Function to validate required variables validate_required_vars() { local missing_vars=() - + # Check for required variables if [ -z "${SPEAKER_NODE:-}" ]; then missing_vars+=("SPEAKER_NODE") fi - + if [ -z "${CONTAINER_REGISTRY:-}" ]; then missing_vars+=("CONTAINER_REGISTRY") fi - + if [ ${#missing_vars[@]} -gt 0 ]; then echo "❌ Error: Missing required environment variables:" for var in "${missing_vars[@]}"; do diff --git a/scripts/k8s/purge-container-images.sh b/scripts/k8s/purge-container-images.sh index 8b9773ef..6272ed5e 100755 --- a/scripts/k8s/purge-container-images.sh +++ b/scripts/k8s/purge-container-images.sh @@ -2,7 +2,7 @@ # Script to purge unused container images from the local container runtime # Only removes images that are not currently being used by any pods or deployments -# +# # Usage: ./purge-container-images.sh [pass=PASSWORD] # Example: ./purge-container-images.sh pass=mysudopassword @@ -130,7 +130,7 @@ FAILED_COUNT=0 while IFS= read -r image || [[ -n "$image" ]]; do echo " Removing container image: $image" - + # Use microk8s ctr to remove the image if [[ -n "$SUDO_PASSWORD" ]]; then # Use password with sudo -S diff --git a/scripts/k8s/purge-images.sh b/scripts/k8s/purge-images.sh index 84c5b2d1..730eb1d5 100755 --- a/scripts/k8s/purge-images.sh +++ b/scripts/k8s/purge-images.sh @@ -20,4 +20,4 @@ echo "=====================================" "$SCRIPT_DIR/purge-container-images.sh" echo "" -echo "🎉 All purge operations completed!" \ No newline at end of file +echo "🎉 All purge operations completed!" diff --git a/scripts/k8s/purge-registry-images.sh b/scripts/k8s/purge-registry-images.sh index 2f057c37..3b802ce0 100755 --- a/scripts/k8s/purge-registry-images.sh +++ b/scripts/k8s/purge-registry-images.sh @@ -98,18 +98,18 @@ FAILED_COUNT=0 while IFS= read -r image || [[ -n "$image" ]]; do echo " Removing from registry: $image" - + # Extract repository and tag from full image name (registry/repo:tag) repo_tag=$(echo "$image" | sed "s|^$REGISTRY/||") repo=$(echo "$repo_tag" | cut -d':' -f1) tag=$(echo "$repo_tag" | cut -d':' -f2) - + # Delete using Docker Registry API # First get the digest digest=$(curl -s -I -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ "http://$REGISTRY/v2/$repo/manifests/$tag" | \ grep -i docker-content-digest | cut -d' ' -f2 | tr -d '\r') - + if [[ -n "$digest" ]]; then if curl -s -X DELETE "http://$REGISTRY/v2/$repo/manifests/$digest" >/dev/null 2>&1; then ((PURGED_COUNT++)) @@ -133,7 +133,7 @@ echo " Remaining registry images: $((TOTAL_COUNT - PURGED_COUNT))" if [ "$PURGED_COUNT" -gt 0 ]; then echo "" echo "🗑️ Running garbage collection to free disk space..." - + # Try to run garbage collection on the registry # This requires access to the registry container or filesystem NODE_NAME="${SPEAKER_NODE:-}" diff --git a/scripts/k8s/setup-storage.sh b/scripts/k8s/setup-storage.sh index 560634b0..a6f59a5b 100755 --- a/scripts/k8s/setup-storage.sh +++ b/scripts/k8s/setup-storage.sh @@ -45,10 +45,10 @@ read -p "Choose option (1 or 2): " choice case $choice in 1) echo -e "${GREEN}Setting up simple storage configuration...${NC}" - + # Update values.yaml to disable shared models sed -i 's/enabled: true/enabled: false/' extras/speaker-recognition/charts/values.yaml - + echo -e "${GREEN}✅ Simple storage configured!${NC}" echo -e "${YELLOW}Each pod will download models independently.${NC}" echo @@ -57,22 +57,22 @@ case $choice in echo -e " ${YELLOW}skaffold run --profile speaker-recognition --default-repo=${CONTAINER_REGISTRY:-localhost:32000}${NC}" echo ;; - + 2) echo -e "${GREEN}Setting up shared storage configuration...${NC}" - + # Check available storage classes echo -e "${YELLOW}Available storage classes:${NC}" kubectl get storageclass - + echo read -p "Enter storage class name (or press Enter for 'openebs-hostpath'): " storage_class storage_class=${storage_class:-"openebs-hostpath"} - + # Update values.yaml to enable shared models sed -i 's/enabled: false/enabled: true/' extras/speaker-recognition/charts/values.yaml sed -i "s/storageClassName: \"openebs-hostpath\"/storageClassName: \"${storage_class}\"/" extras/speaker-recognition/charts/values.yaml - + echo -e "${GREEN}✅ Shared storage configured!${NC}" echo -e "${YELLOW}Storage class: ${storage_class}${NC}" echo @@ -83,7 +83,7 @@ case $choice in echo -e " ${YELLOW}kubectl logs -n speech -l app.kubernetes.io/component=speaker -f${NC}" echo ;; - + *) echo -e "${RED}Invalid choice. Please run the script again and choose 1 or 2.${NC}" exit 1 diff --git a/scripts/lib/env_utils.py b/scripts/lib/env_utils.py index 8abe13a3..332ed75b 100644 --- a/scripts/lib/env_utils.py +++ b/scripts/lib/env_utils.py @@ -7,59 +7,69 @@ from pathlib import Path from typing import Dict, Set +from dotenv import dotenv_values + + def get_config_env_variables() -> Set[str]: """Get list of variable names defined in config.env""" config_env_path = Path(__file__).parent.parent.parent / "config.env" - variables = set() - - if config_env_path.exists(): - with open(config_env_path, 'r') as f: - for line in f: - line = line.strip() - if line and not line.startswith('#') and '=' in line: - var_name = line.split('=')[0].strip() - variables.add(var_name) - - return variables + + if not config_env_path.exists(): + return set() + + return set(dotenv_values(str(config_env_path)).keys()) + def get_resolved_env_vars() -> Dict[str, str]: """Get all config.env variables with their resolved values from environment""" config_variables = get_config_env_variables() resolved_vars = {} - + for var_name in config_variables: var_value = os.environ.get(var_name) if var_value: # Only include if variable has a value resolved_vars[var_name] = var_value - + return resolved_vars -def classify_secrets(variables: Dict[str, str]) -> tuple[Dict[str, str], Dict[str, str]]: + +def classify_secrets( + variables: Dict[str, str] +) -> tuple[Dict[str, str], Dict[str, str]]: """Classify variables into secrets and regular config""" secrets = {} config = {} - + secret_patterns = ["SECRET", "KEY", "TOKEN", "PASSWORD"] - + for var_name, var_value in variables.items(): if any(pattern in var_name.upper() for pattern in secret_patterns): secrets[var_name] = var_value else: config[var_name] = var_value - + return config, secrets + def get_skaffold_variables() -> Set[str]: """Get only the variables needed by Skaffold templates""" return { - 'APPLICATION_NAMESPACE', 'INFRASTRUCTURE_NAMESPACE', - 'BACKEND_HOST', 'WEBUI_HOST', 'SPEAKER_HOST', 'EXTERNAL_DOMAIN', - 'BACKEND_NODEPORT', 'WEBUI_NODEPORT', - 'COMPUTE_MODE', 'VITE_ALLOWED_HOSTS', 'SPEAKER_NODE' + "APPLICATION_NAMESPACE", + "INFRASTRUCTURE_NAMESPACE", + "BACKEND_HOST", + "WEBUI_HOST", + "SPEAKER_HOST", + "EXTERNAL_DOMAIN", + "BACKEND_NODEPORT", + "WEBUI_NODEPORT", + "COMPUTE_MODE", + "VITE_ALLOWED_HOSTS", + "SPEAKER_NODE", } + def format_variable(var_name: str, var_value: str, service_name: str = None) -> str: """Format a variable based on service-specific rules.""" - if service_name == 'skaffold' and var_name == 'VITE_ALLOWED_HOSTS': + if service_name == "skaffold" and var_name == "VITE_ALLOWED_HOSTS": return f'{var_name}="{var_value}"' - return f'{var_name}={var_value}' \ No newline at end of file + return f"{var_name}={var_value}" diff --git a/scripts/manage-audio-files.sh b/scripts/manage-audio-files.sh index f02547f1..517637dc 100755 --- a/scripts/manage-audio-files.sh +++ b/scripts/manage-audio-files.sh @@ -31,16 +31,16 @@ get_pod_name() { # Function to list audio files list_audio_files() { echo -e "${GREEN}=== Listing Audio Files ===${NC}" - + echo -e "${YELLOW}Main data directory (/app/data):${NC}" kubectl exec -n $NAMESPACE $POD_NAME -- ls -la $DATA_DIR/ 2>/dev/null || echo "Directory is empty or doesn't exist" - + echo -e "\n${YELLOW}Audio chunks directory (/app/data/audio_chunks):${NC}" kubectl exec -n $NAMESPACE $POD_NAME -- ls -la $AUDIO_CHUNKS_DIR/ 2>/dev/null || echo "Directory is empty or doesn't exist" - + echo -e "\n${YELLOW}Old audio_chunks directory (if exists):${NC}" kubectl exec -n $NAMESPACE $POD_NAME -- ls -la $OLD_AUDIO_DIR/ 2>/dev/null || echo "Old directory doesn't exist" - + echo -e "\n${YELLOW}All audio files in container (excluding test files):${NC}" kubectl exec -n $NAMESPACE $POD_NAME -- find /app -name "*.wav" -o -name "*.mp3" -o -name "*.m4a" -o -name "*.flac" | grep -v ".venv" | grep -v "node_modules" | head -20 } @@ -48,36 +48,36 @@ list_audio_files() { # Function to move audio files from old location to new location move_audio_files() { echo -e "${GREEN}=== Moving Audio Files ===${NC}" - + # Check if old directory exists and has files OLD_FILES=$(kubectl exec -n $NAMESPACE $POD_NAME -- find $OLD_AUDIO_DIR -name "*.wav" -o -name "*.mp3" -o -name "*.m4a" -o -name "*.flac" 2>/dev/null | wc -l) - + if [ "$OLD_FILES" -gt 0 ]; then echo -e "${YELLOW}Found $OLD_FILES audio files in old location. Moving to new location...${NC}" - + # Create the new directory if it doesn't exist kubectl exec -n $NAMESPACE $POD_NAME -- mkdir -p $AUDIO_CHUNKS_DIR - + # Move all audio files kubectl exec -n $NAMESPACE $POD_NAME -- find $OLD_AUDIO_DIR -name "*.wav" -o -name "*.mp3" -o -name "*.m4a" -o -name "*.flac" -exec mv {} $AUDIO_CHUNKS_DIR/ \; - + echo -e "${GREEN}Successfully moved audio files to $AUDIO_CHUNKS_DIR${NC}" else echo -e "${BLUE}No audio files found in old location${NC}" fi - + # Also check if there are audio files in the main data directory that should be in audio_chunks MAIN_DATA_FILES=$(kubectl exec -n $NAMESPACE $POD_NAME -- find $DATA_DIR -maxdepth 1 -name "*.wav" -o -name "*.mp3" -o -name "*.m4a" -o -name "*.flac" 2>/dev/null | wc -l) - + if [ "$MAIN_DATA_FILES" -gt 0 ]; then echo -e "${YELLOW}Found $MAIN_DATA_FILES audio files in main data directory. Moving to audio_chunks subdirectory...${NC}" - + # Create the audio_chunks directory if it doesn't exist kubectl exec -n $NAMESPACE $POD_NAME -- mkdir -p $AUDIO_CHUNKS_DIR - + # Move all audio files from main data directory to audio_chunks kubectl exec -n $NAMESPACE $POD_NAME -- find $DATA_DIR -maxdepth 1 -name "*.wav" -o -name "*.mp3" -o -name "*.m4a" -o -name "*.flac" -exec mv {} $AUDIO_CHUNKS_DIR/ \; - + echo -e "${GREEN}Successfully moved audio files to $AUDIO_CHUNKS_DIR${NC}" else echo -e "${BLUE}No audio files found in main data directory${NC}" @@ -87,7 +87,7 @@ move_audio_files() { # Function to organize audio files by date organize_by_date() { echo -e "${GREEN}=== Organizing Audio Files by Date ===${NC}" - + # Create year/month subdirectories kubectl exec -n $NAMESPACE $POD_NAME -- bash -c " cd $AUDIO_CHUNKS_DIR @@ -110,7 +110,7 @@ organize_by_date() { # Function to clean up old audio files cleanup_old_files() { echo -e "${GREEN}=== Cleaning Up Old Audio Files ===${NC}" - + # Remove files older than 30 days kubectl exec -n $NAMESPACE $POD_NAME -- bash -c " cd $AUDIO_CHUNKS_DIR @@ -129,13 +129,13 @@ cleanup_old_files() { # Function to show disk usage show_disk_usage() { echo -e "${GREEN}=== Disk Usage ===${NC}" - + echo -e "${YELLOW}Audio chunks directory size:${NC}" kubectl exec -n $NAMESPACE $POD_NAME -- du -sh $AUDIO_CHUNKS_DIR 2>/dev/null || echo "Directory doesn't exist" - + echo -e "\n${YELLOW}Total disk usage in /app/data:${NC}" kubectl exec -n $NAMESPACE $POD_NAME -- du -sh /app/data - + echo -e "\n${YELLOW}Available disk space:${NC}" kubectl exec -n $NAMESPACE $POD_NAME -- df -h /app/data } @@ -143,18 +143,18 @@ show_disk_usage() { # Function to test audio endpoint test_audio_endpoint() { echo -e "${GREEN}=== Testing Audio Endpoint ===${NC}" - + # Get a sample audio file from either location SAMPLE_FILE=$(kubectl exec -n $NAMESPACE $POD_NAME -- find $DATA_DIR -name "*.wav" | head -1) - + if [ -n "$SAMPLE_FILE" ]; then FILENAME=$(basename "$SAMPLE_FILE") echo -e "${YELLOW}Testing audio endpoint with file: $FILENAME${NC}" echo -e "${YELLOW}File location: $SAMPLE_FILE${NC}" - + # Test the audio endpoint RESPONSE=$(kubectl exec -n $NAMESPACE $POD_NAME -- curl -s -o /dev/null -w "%{http_code}" "http://localhost:8000/audio/$FILENAME") - + if [ "$RESPONSE" = "200" ]; then echo -e "${GREEN}✅ Audio endpoint is working correctly${NC}" else @@ -171,18 +171,18 @@ delete_all_audio() { echo -e "${YELLOW}⚠️ WARNING: This will permanently delete ALL audio files!${NC}" echo -e "${YELLOW}This action cannot be undone.${NC}" echo - + # Count total audio files first TOTAL_FILES=$(kubectl exec -n $NAMESPACE $POD_NAME -- find $DATA_DIR -name "*.wav" -o -name "*.mp3" -o -name "*.m4a" -o -name "*.flac" 2>/dev/null | wc -l) - + if [ "$TOTAL_FILES" -eq 0 ]; then echo -e "${BLUE}No audio files found to delete.${NC}" return 0 fi - + echo -e "${YELLOW}Found $TOTAL_FILES audio files to delete.${NC}" echo - + # Show some examples of files that will be deleted echo -e "${YELLOW}Example files that will be deleted:${NC}" kubectl exec -n $NAMESPACE $POD_NAME -- find $DATA_DIR -name "*.wav" -o -name "*.mp3" -o -name "*.m4a" -o -name "*.flac" 2>/dev/null | head -5 @@ -190,18 +190,18 @@ delete_all_audio() { echo -e "${YELLOW}... and $((TOTAL_FILES - 5)) more files${NC}" fi echo - + # Confirmation read -p "Are you ABSOLUTELY sure you want to delete ALL audio files? Type 'DELETE_ALL': " confirm echo - + if [ "$confirm" != "DELETE_ALL" ]; then echo -e "${BLUE}Operation cancelled. No files were deleted.${NC}" return 0 fi - + echo -e "${RED}Deleting all audio files...${NC}" - + # Delete all audio files DELETED_COUNT=0 kubectl exec -n $NAMESPACE $POD_NAME -- bash -c " @@ -215,16 +215,16 @@ delete_all_audio() { done echo \"Deleted \$DELETED_COUNT files\" " - + # Verify deletion REMAINING_FILES=$(kubectl exec -n $NAMESPACE $POD_NAME -- find $DATA_DIR -name "*.wav" -o -name "*.mp3" -o -name "*.m4a" -o -name "*.flac" 2>/dev/null | wc -l) - + if [ "$REMAINING_FILES" -eq 0 ]; then echo -e "${GREEN}✅ Successfully deleted all audio files!${NC}" else echo -e "${YELLOW}⚠️ Warning: $REMAINING_FILES files still remain${NC}" fi - + # Show disk usage after deletion echo -e "\n${YELLOW}Disk usage after deletion:${NC}" show_disk_usage @@ -249,7 +249,7 @@ show_menu() { # Main execution main() { get_pod_name - + if [ $# -eq 0 ]; then # Interactive mode while true; do @@ -261,7 +261,7 @@ main() { 4) cleanup_old_files ;; 5) show_disk_usage ;; 6) test_audio_endpoint ;; - 7) + 7) list_audio_files move_audio_files organize_by_date @@ -284,7 +284,7 @@ main() { "usage") show_disk_usage ;; "test") test_audio_endpoint ;; "delete") delete_all_audio ;; - "all") + "all") list_audio_files move_audio_files organize_by_date @@ -297,5 +297,3 @@ main() { } main "$@" - - diff --git a/scripts/pull-images.sh b/scripts/pull-images.sh new file mode 100755 index 00000000..ba077668 --- /dev/null +++ b/scripts/pull-images.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# pull-images.sh — Pull Chronicle images and retag them locally +# +# Usage (GHCR — default, no account needed): +# ./scripts/pull-images.sh v1.0.0 +# +# Usage (custom registry): +# CHRONICLE_REGISTRY=ghcr.io/myuser/ ./scripts/pull-images.sh v1.0.0 +# DOCKERHUB_USERNAME=myuser ./scripts/pull-images.sh v1.0.0 # backward compat +# +# After pulling, start with the prebuilt images: +# ./start.sh --use-prebuilt v1.0.0 + +set -euo pipefail + +# ── Colour helpers ───────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +info() { echo -e "${CYAN}ℹ️ $*${RESET}"; } +success() { echo -e "${GREEN}✅ $*${RESET}"; } +warn() { echo -e "${YELLOW}⚠️ $*${RESET}"; } +error() { echo -e "${RED}❌ $*${RESET}" >&2; } + +# ── Validate inputs ──────────────────────────────────────────────────────────── +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + error "TAG argument is required." + echo " Example: ./scripts/pull-images.sh v1.0.0" >&2 + exit 1 +fi + +# Registry precedence: CHRONICLE_REGISTRY > DOCKERHUB_USERNAME > default GHCR +if [[ -n "${CHRONICLE_REGISTRY:-}" ]]; then + REGISTRY="${CHRONICLE_REGISTRY}" +elif [[ -n "${DOCKERHUB_USERNAME:-}" ]]; then + REGISTRY="${DOCKERHUB_USERNAME}/" +else + REGISTRY="ghcr.io/simpleopensoftware/" +fi + +# ── Image inventory ──────────────────────────────────────────────────────────── +# Format: "local-image-name:registry-image-name" +# After pulling, each remote image is retagged to ":" +# so that docker-compose can find it when CHRONICLE_TAG= is set. +IMAGES=( + "chronicle-backend:chronicle-backend" + "chronicle-speaker:chronicle-speaker" + "chronicle-speaker-strixhalo:chronicle-speaker-strixhalo" + "chronicle-speaker-webui:chronicle-speaker-webui" + "chronicle-asr-nemo:chronicle-asr-nemo" + "chronicle-asr-nemo-strixhalo:chronicle-asr-nemo-strixhalo" + "chronicle-asr-faster-whisper:chronicle-asr-faster-whisper" + "chronicle-asr-vibevoice:chronicle-asr-vibevoice" + "chronicle-asr-vibevoice-strixhalo:chronicle-asr-vibevoice-strixhalo" + "chronicle-asr-transformers:chronicle-asr-transformers" + "chronicle-asr-qwen3-wrapper:chronicle-asr-qwen3-wrapper" + "chronicle-asr-qwen3-bridge:chronicle-asr-qwen3-bridge" + "chronicle-havpe-relay:chronicle-havpe-relay" +) + +echo "" +echo -e "${BOLD}Chronicle Image Pull${RESET}" +echo -e " Registry : ${REGISTRY}" +echo -e " Tag : ${TAG}" +echo "" + +# ── Pull loop ───────────────────────────────────────────────────────────────── +PULLED=() +FAILED=() + +for entry in "${IMAGES[@]}"; do + LOCAL_BASE="${entry%%:*}" + REMOTE_BASE="${REGISTRY}${entry##*:}" + REMOTE_TAG="${REMOTE_BASE}:${TAG}" + LOCAL_TAG="${LOCAL_BASE}:${TAG}" + + echo -e "${CYAN}── ${entry##*:}${RESET}" + info " Pulling ← ${REMOTE_TAG}" + + if docker pull "${REMOTE_TAG}"; then + # Retag to local name so docker-compose finds it with CHRONICLE_TAG= + info " Retagging → ${LOCAL_TAG}" + docker tag "${REMOTE_TAG}" "${LOCAL_TAG}" + success " Ready as ${LOCAL_TAG}" + PULLED+=("${entry##*:}") + else + warn " Not found in registry — skipping (this service may not have been pushed)" + FAILED+=("${entry##*:}") + fi + echo "" +done + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo -e "${BOLD}── Summary ──────────────────────────────────────────────────────${RESET}" +printf "%-40s %s\n" "Image" "Status" +printf "%-40s %s\n" "─────────────────────────────────────" "──────" + +for entry in "${IMAGES[@]}"; do + IMAGE_NAME="${entry##*:}" + STATUS="" + for p in "${PULLED[@]}"; do + [[ "$p" == "$IMAGE_NAME" ]] && STATUS="${GREEN}pulled${RESET}" && break + done + for f in "${FAILED[@]}"; do + [[ "$f" == "$IMAGE_NAME" ]] && STATUS="${YELLOW}not found${RESET}" && break + done + [[ -z "$STATUS" ]] && STATUS="${YELLOW}not found${RESET}" + printf "%-40s " "${IMAGE_NAME}" + echo -e "${STATUS}" +done + +echo "" +if [[ ${#PULLED[@]} -gt 0 ]]; then + success "Pulled ${#PULLED[@]} image(s) tagged as ${TAG}" + echo "" + echo "Start services with prebuilt images:" + echo -e " ${BOLD}./start.sh --use-prebuilt ${TAG}${RESET}" +fi +if [[ ${#FAILED[@]} -gt 0 ]]; then + warn "${#FAILED[@]} image(s) not found in registry (these services will fall back to local builds)" +fi diff --git a/scripts/push-images.sh b/scripts/push-images.sh new file mode 100755 index 00000000..54f88efe --- /dev/null +++ b/scripts/push-images.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# push-images.sh — Tag and push locally-built Chronicle images to a registry +# +# Usage: +# CHRONICLE_PUSH_REGISTRY=ghcr.io/myuser/ ./scripts/push-images.sh v1.0.0 "stable before refactor" +# DOCKERHUB_USERNAME=myuser ./scripts/push-images.sh v1.0.0 "description" # backward compat +# +# Requirements: +# - Images must already be built locally (run ./start.sh --build first) +# - CHRONICLE_PUSH_REGISTRY or DOCKERHUB_USERNAME env var must be set +# - Must be logged in to the target registry (docker login) + +set -euo pipefail + +# ── Colour helpers ───────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +info() { echo -e "${CYAN}ℹ️ $*${RESET}"; } +success() { echo -e "${GREEN}✅ $*${RESET}"; } +warn() { echo -e "${YELLOW}⚠️ $*${RESET}"; } +error() { echo -e "${RED}❌ $*${RESET}" >&2; } + +# ── Validate inputs ──────────────────────────────────────────────────────────── +# Registry precedence: CHRONICLE_PUSH_REGISTRY > DOCKERHUB_USERNAME +if [[ -n "${CHRONICLE_PUSH_REGISTRY:-}" ]]; then + REGISTRY="${CHRONICLE_PUSH_REGISTRY}" +elif [[ -n "${DOCKERHUB_USERNAME:-}" ]]; then + REGISTRY="${DOCKERHUB_USERNAME}/" +else + error "CHRONICLE_PUSH_REGISTRY or DOCKERHUB_USERNAME env var is required." + echo " GHCR: CHRONICLE_PUSH_REGISTRY=ghcr.io/yourusername/ ./scripts/push-images.sh v1.0.0" >&2 + echo " DockerHub: DOCKERHUB_USERNAME=yourusername ./scripts/push-images.sh v1.0.0" >&2 + exit 1 +fi + +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + error "TAG argument is required." + echo " Example: CHRONICLE_PUSH_REGISTRY=ghcr.io/myuser/ ./scripts/push-images.sh v1.0.0 'description'" >&2 + exit 1 +fi + +DESCRIPTION="${2:-}" + +# ── Image inventory ──────────────────────────────────────────────────────────── +# Format: "local-image-name:registry-image-name" +# local-image-name = what docker-compose builds to locally (with empty CHRONICLE_REGISTRY) +# registry-image-name = what gets pushed to DockerHub +IMAGES=( + "chronicle-backend:chronicle-backend" + "chronicle-speaker:chronicle-speaker" + "chronicle-speaker-strixhalo:chronicle-speaker-strixhalo" + "chronicle-speaker-webui:chronicle-speaker-webui" + "chronicle-asr-nemo:chronicle-asr-nemo" + "chronicle-asr-nemo-strixhalo:chronicle-asr-nemo-strixhalo" + "chronicle-asr-faster-whisper:chronicle-asr-faster-whisper" + "chronicle-asr-vibevoice:chronicle-asr-vibevoice" + "chronicle-asr-vibevoice-strixhalo:chronicle-asr-vibevoice-strixhalo" + "chronicle-asr-transformers:chronicle-asr-transformers" + "chronicle-asr-qwen3-vllm:chronicle-asr-qwen3-vllm" + "chronicle-asr-qwen3-wrapper:chronicle-asr-qwen3-wrapper" + "chronicle-asr-qwen3-bridge:chronicle-asr-qwen3-bridge" + "chronicle-havpe-relay:chronicle-havpe-relay" +) + +# ── Collect git info ─────────────────────────────────────────────────────────── +GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") +DATE=$(date +%Y-%m-%d) + +echo "" +echo -e "${BOLD}Chronicle Image Push${RESET}" +echo -e " Registry : ${REGISTRY}" +echo -e " Tag : ${TAG}" +echo -e " Git SHA : ${GIT_SHA}" +echo -e " Date : ${DATE}" +[[ -n "$DESCRIPTION" ]] && echo -e " Desc : ${DESCRIPTION}" +echo "" + +# ── Push loop ───────────────────────────────────────────────────────────────── +PUSHED=() +SKIPPED=() + +for entry in "${IMAGES[@]}"; do + LOCAL_NAME="${entry%%:*}:latest" + REMOTE_BASE="${REGISTRY}${entry##*:}" + REMOTE_TAG="${REMOTE_BASE}:${TAG}" + REMOTE_LATEST="${REMOTE_BASE}:latest" + + echo -e "${CYAN}── ${LOCAL_NAME}${RESET}" + + # Check if image exists locally + if ! docker image inspect "${LOCAL_NAME}" > /dev/null 2>&1; then + warn " Not found locally — skipping (run ./start.sh --build to build it first)" + SKIPPED+=("${LOCAL_NAME}") + continue + fi + + # Tag and push versioned tag + info " Tagging → ${REMOTE_TAG}" + docker tag "${LOCAL_NAME}" "${REMOTE_TAG}" + + info " Pushing → ${REMOTE_TAG}" + if docker push "${REMOTE_TAG}"; then + success " Pushed ${REMOTE_TAG}" + else + error " Failed to push ${REMOTE_TAG}" + SKIPPED+=("${LOCAL_NAME}") + continue + fi + + # Also tag and push :latest + info " Tagging → ${REMOTE_LATEST}" + docker tag "${LOCAL_NAME}" "${REMOTE_LATEST}" + + info " Pushing → ${REMOTE_LATEST}" + if docker push "${REMOTE_LATEST}"; then + success " Pushed ${REMOTE_LATEST}" + else + warn " Failed to push :latest tag (versioned tag was already pushed)" + fi + + PUSHED+=("${entry##*:}") + echo "" +done + +# ── Update releases.json ─────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RELEASES_FILE="${SCRIPT_DIR}/releases.json" + +# Build JSON array of pushed image names +IMAGES_JSON="[" +for i in "${!PUSHED[@]}"; do + [[ $i -gt 0 ]] && IMAGES_JSON+="," + IMAGES_JSON+="\"${PUSHED[$i]}\"" +done +IMAGES_JSON+="]" + +NEW_ENTRY="{\"tag\":\"${TAG}\",\"description\":\"${DESCRIPTION}\",\"date\":\"${DATE}\",\"git_sha\":\"${GIT_SHA}\",\"images_pushed\":${IMAGES_JSON}}" + +if [[ -f "$RELEASES_FILE" ]]; then + # Append to existing array + EXISTING=$(cat "$RELEASES_FILE") + # Strip trailing ] and append new entry + TRIMMED="${EXISTING%]}" + # Handle empty array + if [[ "$TRIMMED" == "[" ]]; then + echo "[${NEW_ENTRY}]" > "$RELEASES_FILE" + else + echo "${TRIMMED},${NEW_ENTRY}]" > "$RELEASES_FILE" + fi +else + echo "[${NEW_ENTRY}]" > "$RELEASES_FILE" +fi + +success "Recorded release in scripts/releases.json" + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}── Summary ──────────────────────────────────────────────────────${RESET}" +printf "%-40s %s\n" "Image" "Status" +printf "%-40s %s\n" "─────────────────────────────────────" "──────" + +for entry in "${IMAGES[@]}"; do + IMAGE_NAME="${entry##*:}" + LOCAL_NAME="${entry%%:*}:latest" + STATUS="" + for p in "${PUSHED[@]}"; do + [[ "$p" == "$IMAGE_NAME" ]] && STATUS="${GREEN}pushed${RESET}" && break + done + for s in "${SKIPPED[@]}"; do + [[ "$s" == "$LOCAL_NAME" ]] && STATUS="${YELLOW}skipped${RESET}" && break + done + [[ -z "$STATUS" ]] && STATUS="${YELLOW}skipped${RESET}" + printf "%-40s " "${IMAGE_NAME}" + echo -e "${STATUS}" +done + +echo "" +if [[ ${#PUSHED[@]} -gt 0 ]]; then + success "Pushed ${#PUSHED[@]} image(s) as ${TAG}" + echo "" + echo "To restore this snapshot:" + echo " CHRONICLE_REGISTRY=${REGISTRY} ./scripts/pull-images.sh ${TAG}" + echo " CHRONICLE_REGISTRY=${REGISTRY} ./start.sh --use-prebuilt ${TAG}" +fi +if [[ ${#SKIPPED[@]} -gt 0 ]]; then + warn "${#SKIPPED[@]} image(s) were skipped (not found locally)" +fi diff --git a/services.py b/services.py index a81d8428..4f4060ce 100755 --- a/services.py +++ b/services.py @@ -5,64 +5,454 @@ """ import argparse +import json +import os +import secrets +import shlex +import shutil +import signal import subprocess +import sys +import time from pathlib import Path +import requests import yaml +from config_manager import ConfigManager +from dotenv import dotenv_values, set_key from rich.console import Console +from rich.markup import escape from rich.table import Table -from dotenv import dotenv_values - -from setup_utils import read_env_value +from setup_utils import ensure_tailscale_cert, read_env_value console = Console() + def load_config_yml(): """Load config.yml from repository root""" - config_path = Path(__file__).parent / 'config' / 'config.yml' + config_path = Path(__file__).parent / "config" / "config.yml" if not config_path.exists(): return None try: - with open(config_path, 'r') as f: + with open(config_path, "r") as f: return yaml.safe_load(f) except Exception as e: - console.print(f"[yellow]⚠️ Warning: Could not load config/config.yml: {e}[/yellow]") + console.print( + f"[yellow]⚠️ Warning: Could not load config/config.yml: {e}[/yellow]" + ) return None + +def _engine_config(): + """Resolve (engine, compose_argv). Precedence: env > config.yml > docker default. + + The compose front-end differs per engine: docker uses the compose plugin + (`docker compose`); podman uses `podman-compose`, whose CLI path performs CDI + GPU injection (podman 4.x's docker-compat socket API does not). + """ + cfg = load_config_yml() or {} + engine = ( + os.environ.get("CONTAINER_ENGINE") or cfg.get("container_engine") or "docker" + ) + default_compose = "podman-compose" if engine == "podman" else "docker compose" + compose_raw = ( + os.environ.get("COMPOSE_CMD") or cfg.get("compose_cmd") or default_compose + ) + return engine, shlex.split(compose_raw) + + +def container_engine(): + """Container engine binary: 'docker' (default) or 'podman'.""" + return _engine_config()[0] + + +def compose_base(): + """Base argv for compose up/down/build/restart. + + e.g. ['docker', 'compose'] or ['podman-compose']. shlex-split handles both the + two-token plugin form and the single-token podman-compose form transparently. + """ + return list(_engine_config()[1]) + + +def compose_ps_json(service_path): + """List a service's containers as normalized {name, state, status, health} dicts. + + Abstracts over docker vs podman, whose `ps` outputs differ: + - docker: `docker compose ps --format json` (one JSON object per line; native + fields Name/State/Status/Health). + - podman: podman-compose's `ps` is NOT docker-compatible, so query the engine + directly, scoped by the compose project working-dir label that podman-compose + stamps on every container. Native podman fields are Names[]/State/Status, with + health embedded in Status (e.g. "Up 3 seconds (healthy)"). + + Raises on a failed command; callers handle the exception. + """ + engine = container_engine() + service_path = Path(service_path) + + if engine == "podman": + label = ( + "label=com.docker.compose.project.working_dir=" f"{service_path.resolve()}" + ) + result = subprocess.run( + [engine, "ps", "-a", "--filter", label, "--format", "json"], + cwd=service_path, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "podman ps failed") + out = result.stdout.strip() + if not out: + return [] + raw = ( + json.loads(out) + if out.startswith("[") + else [json.loads(line) for line in out.splitlines() if line.strip()] + ) + containers = [] + for c in raw: + names = c.get("Names") or [] + name = ( + names[0] + if isinstance(names, list) and names + else c.get("Name", "unknown") + ) + status = c.get("Status", "unknown") + if "(healthy)" in status: + health = "healthy" + elif "(unhealthy)" in status: + health = "unhealthy" + elif "starting" in status: + health = "starting" + else: + health = "none" + containers.append( + { + "name": name, + "state": c.get("State", "unknown"), + "status": status, + "health": health, + } + ) + return containers + + # docker + result = subprocess.run( + compose_base() + ["ps", "--format", "json"], + cwd=service_path, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "docker compose ps failed") + containers = [] + for line in result.stdout.strip().split("\n"): + if not line: + continue + try: + c = json.loads(line) + except json.JSONDecodeError: + continue + containers.append( + { + "name": c.get("Name", "unknown"), + "state": c.get("State", "unknown"), + "status": c.get("Status", "unknown"), + "health": c.get("Health", "none"), + } + ) + return containers + + SERVICES = { - 'backend': { - 'path': 'backends/advanced', - 'compose_file': 'docker-compose.yml', - 'description': 'Advanced Backend + WebUI', - 'ports': ['8000', '5173'] + "langfuse": { + "path": "extras/langfuse", + "compose_file": "docker-compose.yml", + "description": "LangFuse Observability & Prompt Management", + "ports": ["3002"], + "health_endpoints": [ + ("langfuse", None, "3002", "/api/public/health"), + ], }, - 'speaker-recognition': { - 'path': 'extras/speaker-recognition', - 'compose_file': 'docker-compose.yml', - 'description': 'Speaker Recognition Service', - 'ports': ['8085', '5174/8444'] + "backend": { + "path": "backends/advanced", + "compose_file": "docker-compose.yml", + "description": "Advanced Backend + WebUI", + "ports": ["8000", "5173"], + "health_endpoints": [ + ("backend", "BACKEND_PUBLIC_PORT", "8000", "/readiness"), + ], }, - 'asr-services': { - 'path': 'extras/asr-services', - 'compose_file': 'docker-compose.yml', - 'description': 'Parakeet ASR Service', - 'ports': ['8767'] + "speaker-recognition": { + "path": "extras/speaker-recognition", + "compose_file": "docker-compose.yml", + "description": "Speaker Recognition Service", + "ports": ["8085", "5174/8444"], + "health_endpoints": [ + ("speaker", "SPEAKER_SERVICE_PORT", "8085", "/health"), + ], }, - 'openmemory-mcp': { - 'path': 'extras/openmemory-mcp', - 'compose_file': 'docker-compose.yml', - 'description': 'OpenMemory MCP Server', - 'ports': ['8765'] + "asr-services": { + "path": "extras/asr-services", + "compose_file": "docker-compose.yml", + "description": "Parakeet ASR Service", + "ports": ["8767"], + "health_endpoints": [ + ("asr", "ASR_PORT", "8767", "/health"), + ], + }, + "llm-services": { + "path": "extras/llm-services", + "compose_file": "docker-compose.yml", + "description": "Local LLM via llama.cpp (chat + embeddings)", + "ports": ["8083", "8082"], + "health_endpoints": [ + ("chat", "LLM_PORT", "8083", "/health"), + ("embeddings", "EMBED_PORT", "8082", "/health"), + ], + }, + "wakeword-service": { + "path": "extras/wakeword-service", + "compose_file": "docker-compose.yml", + "description": "Hermes Acoustic Wake-Word Detection", + "ports": ["8771"], + "health_endpoints": [ + ("wakeword", "WAKEWORD_PORT", "8771", "/health"), + ], + }, + "tts": { + "path": "extras/tts", + "compose_file": "docker-compose.yml", + "description": "Text-to-Speech (TADA / Fish Speech / KittenTTS / Kokoro)", + "ports": ["8770"], + "health_endpoints": [ + ("tts", "TTS_PORT", "8770", "/health"), + ], }, - 'langfuse': { - 'path': 'extras/langfuse', - 'compose_file': 'docker-compose.yml', - 'description': 'LangFuse Observability & Prompt Management', - 'ports': ['3002'] - } } + +_DISCOVERY_NAMES = { + "backend": "chronicle-backend", + "speaker-recognition": "chronicle-speaker", + "asr-services": "chronicle-asr", + "llm-services": "chronicle-llm", + "wakeword-service": "chronicle-wakeword-service", + "tts": "chronicle-tts", +} + + +_ADVERTISED_SERVICES_PATH = ( + Path(__file__).parent / "config" / "advertised-services.json" +) + +_ASR_PROVIDER_LABELS = { + "vibevoice": "VibeVoice ASR", + "vibevoice-strixhalo": "VibeVoice ASR (Strix Halo)", + "faster-whisper": "Faster Whisper ASR", + "transformers": "Transformers ASR", + "nemo": "NeMo ASR", + "nemo-strixhalo": "NeMo ASR (Strix Halo)", + "parakeet": "Parakeet ASR", + "qwen3-asr": "Qwen3 ASR", + "gemma4": "Gemma 4 ASR", + "af-next": "Audio Flamingo Next ASR", + "granite": "Granite Speech ASR", + "nemotron": "Nemotron ASR (batch + streaming)", +} + +# TTS provider key (written to extras/tts/.env by init.py) → docker compose service. +_TTS_PROVIDER_TO_SERVICE = { + "tada": "tada-tts", + "fish_speech": "fish-tts", + "kittentts": "kittentts-tts", + "kokoro": "kokoro-tts", +} + +# Batch ASR provider (ASR_PROVIDER) → docker compose service in extras/asr-services. +ASR_PROVIDER_TO_SERVICE = { + "vibevoice": "vibevoice-asr", + "vibevoice-strixhalo": "vibevoice-asr-strixhalo", + "faster-whisper": "faster-whisper-asr", + "transformers": "transformers-asr", + "nemo": "nemo-asr", + "nemo-strixhalo": "nemo-asr-strixhalo", + "parakeet": "parakeet-asr", + "qwen3-asr": "qwen3-asr-wrapper", + "gemma4": "gemma4-asr", + "af-next": "af-next-asr", + "granite": "granite-asr", + # Nemotron serves BOTH batch (HTTP /transcribe) and streaming (ws /stream) from + # one container on 8772, so the batch lane reuses the streaming service. + "nemotron": "nemotron-stream-asr", +} + +# Streaming ASR provider (STREAMING_ASR_PROVIDER) options. Single source of truth +# for the System-page streaming selector: maps the provider key to its docker +# compose service (None = cloud provider, no local container), the stt_stream +# model registry entry the pipeline should use, and a UI label. A streaming +# stt_stream can run alongside a different batch stt provider (e.g. batch=vibevoice +# on 8767, streaming=nemotron on 8772) or be a pure cloud service (smallest/deepgram). +STREAMING_ASR_PROVIDER_OPTIONS = { + "nemotron": { + "service": "nemotron-stream-asr", + "model": "stt-nemotron-stream", + "label": "Nemotron 3.5 (local · 8772)", + }, + "smallest": { + "service": None, + "model": "stt-smallest-stream", + "label": "Smallest.ai PULSE (cloud)", + }, + "deepgram": { + "service": None, + "model": "stt-deepgram-stream", + "label": "Deepgram Nova 3 (cloud)", + }, + "qwen3-asr": { + "service": "qwen3-asr-bridge", + "model": "stt-qwen3-asr-stream", + "label": "Qwen3-ASR (local)", + }, +} + +# Provider key → docker compose service for streaming providers that run a local +# container (cloud providers are absent). Derived from the options above so the +# start logic and the UI selector never drift. +STREAMING_ASR_PROVIDER_TO_SERVICE = { + key: opt["service"] + for key, opt in STREAMING_ASR_PROVIDER_OPTIONS.items() + if opt["service"] +} + + +def active_streaming_asr_provider() -> str: + """Return the provider key whose stt_stream model is the active default. + + Reads ``defaults.stt_stream`` from config.yml (the real pipeline source of + truth — cloud providers leave STREAMING_ASR_PROVIDER empty) and reverse-maps + it through STREAMING_ASR_PROVIDER_OPTIONS. Empty string if unset/unknown. + """ + try: + cfg = yaml.safe_load( + (Path(__file__).parent / "config" / "config.yml").read_text() + ) + model = (cfg.get("defaults") or {}).get("stt_stream") + except (OSError, yaml.YAMLError): + return "" + for key, opt in STREAMING_ASR_PROVIDER_OPTIONS.items(): + if opt["model"] == model: + return key + return "" + + +def asr_needs_local_container(env_values: dict | None = None) -> bool: + """Whether the asr-services compose has any local container to run. + + True when either lane selects a provider that runs a local container — the + batch lane (ASR_PROVIDER → ASR_PROVIDER_TO_SERVICE) or the streaming lane + (STREAMING_ASR_PROVIDER → STREAMING_ASR_PROVIDER_TO_SERVICE). Cloud-only + selections (smallest/deepgram) need no container, so asr-services can stay + disabled in config.yml. Reads extras/asr-services/.env unless ``env_values`` + is supplied. + """ + if env_values is None: + env_file = Path(__file__).parent / SERVICES["asr-services"]["path"] / ".env" + env_values = dotenv_values(env_file) if env_file.exists() else {} + asr_provider = (env_values.get("ASR_PROVIDER") or "").strip("'\"") + streaming_provider = (env_values.get("STREAMING_ASR_PROVIDER") or "").strip("'\"") + return bool(ASR_PROVIDER_TO_SERVICE.get(asr_provider)) or bool( + STREAMING_ASR_PROVIDER_TO_SERVICE.get(streaming_provider) + ) + + +def set_service_enabled(name: str, enabled: bool) -> bool: + """Flip one service's enabled flag in config.yml (``services:`` section). + + Preserves comments (ConfigManager uses ruamel). Returns True if the value + actually changed. Used by the node agent so a provider switch keeps the + lifecycle/UI enabled set consistent with the chosen provider (a cloud ASR + provider needs no container, a local one does). + """ + cm = ConfigManager() + enabled_map = cm.get_enabled_services() + if bool(enabled_map.get(name)) == bool(enabled): + return False + enabled_map[name] = bool(enabled) + cm.set_enabled_services(enabled_map) + return True + + +def _asr_health_port(env_values: dict, default_port) -> str: + """Resolve the port the active ASR provider actually serves health on. + + Most providers bind ASR_PORT (8767). Nemotron is special: when it's the + batch provider it serves both batch + streaming from the nemotron-stream-asr + container, which binds NEMOTRON_STREAM_PORT (8772) — NOT ASR_PORT. Probing + ASR_PORT there leaves the service stuck "starting" forever. + """ + provider = (env_values.get("ASR_PROVIDER") or "").strip("'\"") + if provider == "nemotron": + return str(env_values.get("NEMOTRON_STREAM_PORT", "8772")).strip("'\"") + return str(env_values.get("ASR_PORT", default_port)).strip("'\"") + + +def _get_advertised_services() -> list[tuple[str, int, str]]: + """Return list of (discovery_name, port, label) for configured services.""" + triples: list[tuple[str, int, str]] = [] + for svc_name, discovery_name in _DISCOVERY_NAMES.items(): + if svc_name not in SERVICES or not check_service_enabled(svc_name): + continue + service = SERVICES[svc_name] + endpoints = service.get("health_endpoints", []) + if not endpoints: + continue + _label, port_env, default_port, _path = endpoints[0] + if port_env: + env_path = Path(service["path"]) / ".env" + env_values = dotenv_values(env_path) if env_path.exists() else {} + if svc_name == "asr-services" and port_env == "ASR_PORT": + port = int(_asr_health_port(env_values, default_port)) + else: + port = int(env_values.get(port_env, default_port)) + else: + port = int(default_port) + + # Derive display label + if svc_name == "asr-services": + env_path = Path(service["path"]) / ".env" + asr_provider = "" + if env_path.exists(): + asr_provider = ( + dotenv_values(env_path).get("ASR_PROVIDER", "").strip("'\"") + ) + label = _ASR_PROVIDER_LABELS.get(asr_provider, service["description"]) + else: + label = service["description"] + + triples.append((discovery_name, port, label)) + return triples + + +def _write_advertised_services(triples: list[tuple[str, int, str]]) -> None: + """Write advertised services to config/advertised-services.json for the backend.""" + data = [ + {"name": name, "port": port, "label": label} for name, port, label in triples + ] + _ADVERTISED_SERVICES_PATH.parent.mkdir(parents=True, exist_ok=True) + _ADVERTISED_SERVICES_PATH.write_text(json.dumps(data, indent=2) + "\n") + + +def _remove_advertised_services() -> None: + """Remove advertised-services.json when discovery agent stops.""" + _ADVERTISED_SERVICES_PATH.unlink(missing_ok=True) + + def _get_backend_env_path() -> Path: return Path(__file__).parent / "backends" / "advanced" / ".env" @@ -136,88 +526,215 @@ def _ensure_langfuse_env() -> bool: return True -def check_service_configured(service_name): - """Check if service is configured (has .env file)""" +def check_service_enabled(service_name): + """Whether a service is enabled for the lifecycle. + + Source of truth is the ``services:`` section of config/config.yml (written by + the wizard). This is intentionally decoupled from whether a service's ``.env`` + exists — a stale or half-written ``.env`` no longer counts as "configured". + """ + config = load_config_yml() or {} + return bool(config.get("services", {}).get(service_name, False)) + + +def check_service_health(service_name): + """Check runtime health of a service by hitting its health endpoints. + + Returns (status, detail) where status is one of: + "healthy" — all endpoints responding with < 400 + "partial" — some endpoints down (detail says which) + "unhealthy" — responding but returning errors + "stopped" — not reachable at all + """ service = SERVICES[service_name] - service_path = Path(service['path']) + endpoints = service.get("health_endpoints", []) + if not endpoints: + return ("stopped", "no endpoints defined") - if service_name == 'langfuse': - return (service_path / '.env').exists() + env_path = Path(service["path"]) / ".env" + env_values = dotenv_values(env_path) if env_path.exists() else {} - # Backend uses advanced init, others use .env - if service_name == 'backend': - return (service_path / '.env').exists() - else: - return (service_path / '.env').exists() + results = [] # list of (label, ok: bool) + any_unhealthy = False -def run_compose_command(service_name, command, build=False): + for label, port_env, default_port, path in endpoints: + if service_name == "asr-services" and port_env == "ASR_PORT": + port = _asr_health_port(env_values, default_port) + elif port_env: + port = env_values.get(port_env, default_port) + else: + port = default_port + url = f"http://localhost:{port}{path}" + try: + resp = requests.get(url, timeout=2) + if resp.status_code < 400: + results.append((label, True)) + else: + results.append((label, False)) + any_unhealthy = True + except (requests.ConnectionError, requests.Timeout): + results.append((label, False)) + + up = [r for r in results if r[1]] + down = [r for r in results if not r[1]] + + if len(up) == len(results): + return ("healthy", "") + if len(down) == len(results): + if any_unhealthy: + return ("unhealthy", "") + return ("stopped", "") + down_labels = ", ".join(r[0] for r in down) + return ("partial", f"{down_labels} down") + + +# Profile-gated services in the backend compose. `https` (caddy) is auto-enabled +# from the Caddyfile; the rest are opt-in via the backend BACKEND_PROFILES env var. +_BACKEND_ALL_PROFILES = ("https", "annotation", "vault-sync", "tailscale") + + +def _backend_profile_flags(service_path, command): + """Return ``--profile`` flags for the backend compose. + + On ``down``/``status`` we enable ALL profiles so the command covers + every profile-gated service (caddy, annotation-cron, vault-syncthing, + tailscale) — otherwise ``docker compose down`` silently leaves + inactive-profile containers running (the stale-container bug). + + On ``up`` we only enable what's actually wanted: ``https`` when a Caddyfile is + present (auto), plus any profiles listed in the backend's ``BACKEND_PROFILES`` + env var (comma-separated, e.g. ``annotation,vault-sync``). + """ + if command in ("down", "status"): + profiles = list(_BACKEND_ALL_PROFILES) + else: # up + profiles = [] + caddyfile = service_path / "Caddyfile" + if caddyfile.exists() and caddyfile.is_file(): + profiles.append("https") + env_file = service_path / ".env" + extra = ( + dotenv_values(env_file).get("BACKEND_PROFILES", "") + if env_file.exists() + else "" + ) or "" + for name in extra.split(","): + name = name.strip() + if name and name not in profiles: + profiles.append(name) + + flags = [] + for name in profiles: + flags.extend(["--profile", name]) + return flags + + +def run_compose_command(service_name, command, build=False, force_recreate=False): """Run docker compose command for a service""" service = SERVICES[service_name] - service_path = Path(service['path']) + service_path = Path(service["path"]) if not service_path.exists(): console.print(f"[red]❌ Service directory not found: {service_path}[/red]") return False - compose_file = service_path / service['compose_file'] + compose_file = service_path / service["compose_file"] if not compose_file.exists(): console.print(f"[red]❌ Docker compose file not found: {compose_file}[/red]") return False # Step 1: If build is requested, run build separately first (no timeout for CUDA builds) - if build and command == 'up': + if build and command == "up": + # Stamp the checkout's git-describe into images whose compose files take + # a CHRONICLE_BUILD_VERSION build arg (backend), so a running container + # can report what code it was built from (backend /version endpoint). + # CI overrides this with the release tag before its own compose builds. + import updates # lazy: updates.py imports this module + + os.environ.setdefault( + "CHRONICLE_BUILD_VERSION", updates.repo_version()["describe"] or "dev" + ) # Build command - need to specify profiles for build too - build_cmd = ['docker', 'compose'] + build_cmd = compose_base() # Add profiles to build command (needed for profile-specific services) - if service_name == 'backend': - caddyfile_path = service_path / 'Caddyfile' - if caddyfile_path.exists() and caddyfile_path.is_file(): - build_cmd.extend(['--profile', 'https']) + if service_name == "backend": + build_cmd.extend(_backend_profile_flags(service_path, "up")) - elif service_name == 'speaker-recognition': - env_file = service_path / '.env' + elif service_name == "speaker-recognition": + env_file = service_path / ".env" if env_file.exists(): env_values = dotenv_values(env_file) - # Derive profile from PYTORCH_CUDA_VERSION (cu126/cu121/etc = gpu, cpu = cpu) - pytorch_version = env_values.get('PYTORCH_CUDA_VERSION', 'cpu') - profile = 'gpu' if pytorch_version.startswith('cu') else 'cpu' - build_cmd.extend(['--profile', profile]) + # Derive profile from PYTORCH_CUDA_VERSION + pytorch_version = env_values.get("PYTORCH_CUDA_VERSION", "cpu") + if pytorch_version == "strixhalo": + profile = "strixhalo" + elif pytorch_version.startswith("cu"): + profile = "gpu" + else: + profile = "cpu" + build_cmd.extend(["--profile", profile]) - # For asr-services, only build the selected provider + # For asr-services, only build the selected provider(s) asr_service_to_build = None - if service_name == 'asr-services': - env_file = service_path / '.env' + streaming_asr_service_to_build = None + if service_name == "asr-services": + env_file = service_path / ".env" if env_file.exists(): env_values = dotenv_values(env_file) - asr_provider = env_values.get('ASR_PROVIDER', '').strip("'\"") - - # Map provider to docker service name - provider_to_service = { - 'vibevoice': 'vibevoice-asr', - 'faster-whisper': 'faster-whisper-asr', - 'transformers': 'transformers-asr', - 'nemo': 'nemo-asr', - 'parakeet': 'parakeet-asr', - 'qwen3-asr': 'qwen3-asr-wrapper', - } - asr_service_to_build = provider_to_service.get(asr_provider) + asr_provider = env_values.get("ASR_PROVIDER", "").strip("'\"") + streaming_asr_provider = env_values.get( + "STREAMING_ASR_PROVIDER", "" + ).strip("'\"") + + asr_service_to_build = ASR_PROVIDER_TO_SERVICE.get(asr_provider) + streaming_asr_service_to_build = STREAMING_ASR_PROVIDER_TO_SERVICE.get( + streaming_asr_provider + ) if asr_service_to_build: - console.print(f"[blue]ℹ️ Building ASR provider: {asr_provider} ({asr_service_to_build})[/blue]") + console.print( + f"[blue]ℹ️ Building ASR provider: {asr_provider} ({asr_service_to_build})[/blue]" + ) + if streaming_asr_service_to_build: + console.print( + f"[blue]ℹ️ Building streaming ASR provider: {streaming_asr_provider} ({streaming_asr_service_to_build})[/blue]" + ) + + # For tts, only build the selected provider (one runs at a time) + tts_service_to_build = None + if service_name == "tts": + env_file = service_path / ".env" + if env_file.exists(): + tts_provider = ( + dotenv_values(env_file).get("TTS_PROVIDER", "").strip("'\"") + ) + tts_service_to_build = _TTS_PROVIDER_TO_SERVICE.get(tts_provider) + if tts_service_to_build: + console.print( + f"[blue]ℹ️ Building TTS provider: {tts_provider} ({tts_service_to_build})[/blue]" + ) - build_cmd.append('build') + build_cmd.append("build") # If building ASR, only build the specific service(s) if asr_service_to_build: - if asr_provider == 'qwen3-asr': + if asr_provider == "qwen3-asr": # Qwen3-ASR also needs the streaming bridge built - build_cmd.extend([asr_service_to_build, 'qwen3-asr-bridge']) + build_cmd.extend([asr_service_to_build, "qwen3-asr-bridge"]) else: build_cmd.append(asr_service_to_build) + if streaming_asr_service_to_build: + build_cmd.append(streaming_asr_service_to_build) + + # If building TTS, only build the selected provider + if tts_service_to_build: + build_cmd.append(tts_service_to_build) # Run build with streaming output (no timeout) - console.print(f"[cyan]🔨 Building {service_name} (this may take several minutes for CUDA/GPU builds)...[/cyan]") + console.print( + f"[cyan]🔨 Building {service_name} (this may take several minutes for CUDA/GPU builds)...[/cyan]" + ) try: process = subprocess.Popen( build_cmd, @@ -225,27 +742,33 @@ def run_compose_command(service_name, command, build=False): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - bufsize=1 + bufsize=1, ) if process.stdout is None: - raise RuntimeError("Process stdout is None - unable to read command output") + raise RuntimeError( + "Process stdout is None - unable to read command output" + ) for line in process.stdout: line = line.rstrip() if not line: continue - if 'error' in line.lower() or 'failed' in line.lower(): - console.print(f" [red]{line}[/red]") - elif 'Successfully' in line or 'built' in line.lower(): - console.print(f" [green]{line}[/green]") - elif 'Building' in line or 'Step' in line: - console.print(f" [cyan]{line}[/cyan]") - elif 'warning' in line.lower(): - console.print(f" [yellow]{line}[/yellow]") + # escape() the raw build line so brackets in build output + # (e.g. "COPY ... [/app/.venv ...]") aren't parsed as rich markup, + # which previously raised MarkupError and aborted the build. + safe = escape(line) + if "error" in line.lower() or "failed" in line.lower(): + console.print(f" [red]{safe}[/red]") + elif "Successfully" in line or "built" in line.lower(): + console.print(f" [green]{safe}[/green]") + elif "Building" in line or "Step" in line: + console.print(f" [cyan]{safe}[/cyan]") + elif "warning" in line.lower(): + console.print(f" [yellow]{safe}[/yellow]") else: - console.print(f" [dim]{line}[/dim]") + console.print(f" [dim]{safe}[/dim]") process.wait() @@ -259,94 +782,154 @@ def run_compose_command(service_name, command, build=False): console.print(f"[red]❌ Error building {service_name}: {e}[/red]") return False - # Step 2: Run the actual command (up/down/restart/status) - cmd = ['docker', 'compose'] - - # Add profiles for backend service - if service_name == 'backend': - caddyfile_path = service_path / 'Caddyfile' - if caddyfile_path.exists() and caddyfile_path.is_file(): - cmd.extend(['--profile', 'https']) + # Step 2: Run the actual command (up/down/status) + up_flags = ["up", "-d", "--remove-orphans"] + if force_recreate: + up_flags.append("--force-recreate") + + cmd = compose_base() + + # Add profiles for backend service (down/status cover ALL profiles so no + # profile-gated container is left orphaned; up only enables wanted profiles). + if service_name == "backend": + cmd.extend(_backend_profile_flags(service_path, command)) + + caddyfile_path = service_path / "Caddyfile" + if command == "up" and caddyfile_path.exists() and caddyfile_path.is_file(): + # Only the "static" cert mode keeps a host-issued cert file that we must + # renew. In "caddy" mode Caddy obtains and auto-renews the cert itself, so + # we leave it alone. Renew (if missing/near expiry) before Caddy starts so + # it comes up holding a fresh cert. Cheap no-op when still valid; never + # blocks startup on failure. + cert_mode = read_env_value(str(service_path / ".env"), "HTTPS_CERT_MODE") + if cert_mode == "static": + certs_dir = Path(__file__).parent / "certs" + renewed = ensure_tailscale_cert(str(certs_dir)) + if renewed is True: + console.print("[green]✅ Renewed Tailscale TLS certificate[/green]") + elif renewed is False: + console.print( + "[yellow]⚠️ TLS cert is near expiry but renewal failed " + "(Tailscale unreachable or cert issuance error). Starting with " + "the existing cert; HTTPS clients may see warnings.[/yellow]" + ) # Handle speaker-recognition service specially - if service_name == 'speaker-recognition' and command in ['up', 'down']: - env_file = service_path / '.env' + if service_name == "speaker-recognition" and command in ["up", "down"]: + env_file = service_path / ".env" if env_file.exists(): env_values = dotenv_values(env_file) - # Derive profile from PYTORCH_CUDA_VERSION (cu126/cu121/etc = gpu, cpu = cpu) - pytorch_version = env_values.get('PYTORCH_CUDA_VERSION', 'cpu') - profile = 'gpu' if pytorch_version.startswith('cu') else 'cpu' + # Derive profile from PYTORCH_CUDA_VERSION + pytorch_version = env_values.get("PYTORCH_CUDA_VERSION", "cpu") + if pytorch_version == "strixhalo": + profile = "strixhalo" + elif pytorch_version.startswith("cu"): + profile = "gpu" + else: + profile = "cpu" - cmd.extend(['--profile', profile]) + cmd.extend(["--profile", profile]) - if command == 'up': - https_enabled = env_values.get('REACT_UI_HTTPS', 'false') - if https_enabled.lower() == 'true': - cmd.extend(['up', '-d']) + if command == "up": + caddyfile_path = service_path / "Caddyfile" + https_enabled = caddyfile_path.exists() and caddyfile_path.is_file() + if https_enabled: + cmd.extend(up_flags) else: - cmd.extend(['up', '-d', 'speaker-service-gpu' if profile == 'gpu' else 'speaker-service-cpu', 'web-ui']) - elif command == 'down': - cmd.extend(['down']) + profile_to_service = { + "gpu": "speaker-service-gpu", + "strixhalo": "speaker-service-strixhalo", + "cpu": "speaker-service", + } + cmd.extend( + up_flags + + [profile_to_service.get(profile, "speaker-service"), "web-ui"] + ) + elif command == "down": + cmd.extend(["down"]) else: - if command == 'up': - cmd.extend(['up', '-d']) - elif command == 'down': - cmd.extend(['down']) - - # Handle asr-services - start only the configured provider - elif service_name == 'asr-services' and command in ['up', 'down', 'restart']: - env_file = service_path / '.env' + if command == "up": + cmd.extend(up_flags) + elif command == "down": + cmd.extend(["down"]) + + # Handle asr-services - start only the configured provider(s) + elif service_name == "asr-services" and command in ["up", "down"]: + env_file = service_path / ".env" asr_service_name = None + streaming_asr_service_name = None + asr_provider = "" if env_file.exists(): env_values = dotenv_values(env_file) - asr_provider = env_values.get('ASR_PROVIDER', '').strip("'\"") - - # Map provider to docker service name - provider_to_service = { - 'vibevoice': 'vibevoice-asr', - 'faster-whisper': 'faster-whisper-asr', - 'transformers': 'transformers-asr', - 'nemo': 'nemo-asr', - 'parakeet': 'parakeet-asr', - 'qwen3-asr': 'qwen3-asr-wrapper', - } - asr_service_name = provider_to_service.get(asr_provider) + asr_provider = env_values.get("ASR_PROVIDER", "").strip("'\"") + streaming_asr_provider = env_values.get("STREAMING_ASR_PROVIDER", "").strip( + "'\"" + ) - if asr_service_name: - console.print(f"[blue]ℹ️ Using ASR provider: {asr_provider} ({asr_service_name})[/blue]") + asr_service_name = ASR_PROVIDER_TO_SERVICE.get(asr_provider) + streaming_asr_service_name = STREAMING_ASR_PROVIDER_TO_SERVICE.get( + streaming_asr_provider + ) - if command == 'up': + if asr_service_name: + console.print( + f"[blue]ℹ️ Using ASR provider: {asr_provider} ({asr_service_name})[/blue]" + ) + if streaming_asr_service_name: + console.print( + f"[blue]ℹ️ Using streaming ASR provider: {streaming_asr_provider} ({streaming_asr_service_name})[/blue]" + ) + + if command == "up": if asr_service_name: services_to_start = [asr_service_name] # Qwen3-ASR also needs the streaming bridge - if asr_provider == 'qwen3-asr': - services_to_start.append('qwen3-asr-bridge') - cmd.extend(['up', '-d'] + services_to_start) + if asr_provider == "qwen3-asr": + services_to_start.append("qwen3-asr-bridge") else: - console.print("[yellow]⚠️ No ASR_PROVIDER configured, starting default service[/yellow]") - cmd.extend(['up', '-d', 'vibevoice-asr']) - elif command == 'down': - cmd.extend(['down']) - elif command == 'restart': - if asr_service_name: - services_to_restart = [asr_service_name] - if asr_provider == 'qwen3-asr': - services_to_restart.append('qwen3-asr-bridge') - cmd.extend(['restart'] + services_to_restart) + console.print( + "[yellow]⚠️ No ASR_PROVIDER configured, starting default service[/yellow]" + ) + services_to_start = ["vibevoice-asr"] + if streaming_asr_service_name: + services_to_start.append(streaming_asr_service_name) + cmd.extend(up_flags + services_to_start) + elif command == "down": + cmd.extend(["down"]) + + # Handle tts - start only the configured provider (one runs at a time, port 8770) + elif service_name == "tts" and command in ["up", "down"]: + env_file = service_path / ".env" + tts_service_name = None + if env_file.exists(): + tts_provider = dotenv_values(env_file).get("TTS_PROVIDER", "").strip("'\"") + tts_service_name = _TTS_PROVIDER_TO_SERVICE.get(tts_provider) + if tts_service_name: + console.print( + f"[blue]ℹ️ Using TTS provider: {tts_provider} ({tts_service_name})[/blue]" + ) + + if command == "up": + if tts_service_name: + cmd.extend(up_flags + [tts_service_name]) else: - cmd.extend(['restart']) + console.print( + "[yellow]⚠️ No TTS_PROVIDER configured; run extras/tts/init.py[/yellow]" + ) + cmd.extend(up_flags + ["kittentts-tts"]) + elif command == "down": + # Plain down removes every tts container regardless of provider. + cmd.extend(["down"]) else: # Standard compose commands for other services - if command == 'up': - cmd.extend(['up', '-d']) - elif command == 'down': - cmd.extend(['down']) - elif command == 'restart': - cmd.extend(['restart']) - elif command == 'status': - cmd.extend(['ps']) + if command == "up": + cmd.extend(up_flags) + elif command == "down": + cmd.extend(["down"]) + elif command == "status": + cmd.extend(["ps"]) try: # Run the command with timeout (build already done if needed) @@ -356,7 +939,7 @@ def run_compose_command(service_name, command, build=False): capture_output=True, text=True, check=False, - timeout=120 # 2 minute timeout + timeout=120, # 2 minute timeout ) if result.returncode == 0: @@ -366,33 +949,37 @@ def run_compose_command(service_name, command, build=False): if result.stderr: console.print("[red]Error output:[/red]") for line in result.stderr.splitlines(): - console.print(f" [dim]{line}[/dim]") + console.print(f" [dim]{escape(line)}[/dim]") return False except subprocess.TimeoutExpired: - console.print(f"[red]❌ Command timed out after 2 minutes for {service_name}[/red]") + console.print( + f"[red]❌ Command timed out after 2 minutes for {service_name}[/red]" + ) return False except Exception as e: console.print(f"[red]❌ Error running command: {e}[/red]") return False + def ensure_docker_network(): """Ensure chronicle-network exists""" try: # Check if network already exists + engine = container_engine() result = subprocess.run( - ['docker', 'network', 'inspect', 'chronicle-network'], + [engine, "network", "inspect", "chronicle-network"], capture_output=True, - check=False + check=False, ) if result.returncode != 0: # Network doesn't exist, create it console.print("[blue]📡 Creating chronicle-network...[/blue]") subprocess.run( - ['docker', 'network', 'create', 'chronicle-network'], + [engine, "network", "create", "chronicle-network"], check=True, - capture_output=True + capture_output=True, ) console.print("[green]✅ chronicle-network created[/green]") else: @@ -405,7 +992,710 @@ def ensure_docker_network(): console.print(f"[red]❌ Error checking/creating network: {e}[/red]") return False -def start_services(services, build=False): + +# --- Service manager agent (native process, not Docker) --- +# Host-side HTTP API (edge/service_manager.py) that lets the backend (and thus +# the WebUI System page) start/stop/restart services and switch ASR/TTS +# providers. Runs natively because docker compose needs host bind-mount paths. + +_SERVICE_MANAGER_PID = Path(__file__).parent / "edge" / ".service-manager.pid" +_SERVICE_MANAGER_LOG = Path(__file__).parent / "edge" / "service-manager.log" +_SERVICE_MANAGER_PORT = "8775" + + +def _service_manager_running() -> bool: + """Check if service manager agent process is alive.""" + if _service_manager_managed(): + return _unit_active("chronicle-service-manager") + if not _SERVICE_MANAGER_PID.exists(): + return False + try: + pid = int(_SERVICE_MANAGER_PID.read_text().strip()) + os.kill(pid, 0) + return True + except (ValueError, OSError): + _SERVICE_MANAGER_PID.unlink(missing_ok=True) + return False + + +def _ensure_service_manager_token() -> str: + """Read SERVICE_MANAGER_TOKEN from backend .env, generating it on first use. + + The backend .env is the single source of truth: the backend container gets + the token via env_file, and the agent gets it from here at launch. + """ + backend_env_path = _get_backend_env_path() + token = read_env_value(backend_env_path, "SERVICE_MANAGER_TOKEN") or "" + if not token: + token = secrets.token_hex(24) + backend_env_path.touch(exist_ok=True) + set_key( + str(backend_env_path), "SERVICE_MANAGER_TOKEN", token, quote_mode="never" + ) + console.print( + "[blue]ℹ️ Generated SERVICE_MANAGER_TOKEN in backends/advanced/.env[/blue]" + ) + return token + + +def _start_service_manager(): + """Start the service manager agent as a native background process.""" + # Heal legacy installs: the old standalone discovery agent is now folded in. + _cleanup_legacy_discovery() + if _service_manager_managed(): + _systemctl_user("start", "chronicle-service-manager", capture=False) + console.print( + "[dim]🛠 Service manager managed by systemd (ensured started)[/dim]" + ) + return True + if _service_manager_running(): + console.print("[dim]🛠 Service manager already running[/dim]") + return True + + agent_script = Path(__file__).parent / "edge" / "service_manager.py" + if not agent_script.exists(): + console.print("[yellow]⚠️ edge/service_manager.py not found, skipping[/yellow]") + return False + + env = dict(os.environ) + env["SERVICE_MANAGER_TOKEN"] = _ensure_service_manager_token() + env.setdefault("SERVICE_MANAGER_PORT", _SERVICE_MANAGER_PORT) + + log_file = open(_SERVICE_MANAGER_LOG, "a") + try: + proc = subprocess.Popen( + [ + "uv", + "run", + "--with-requirements", + "setup-requirements.txt", + "--with", + "fastapi", + "--with", + "uvicorn", + "python", + str(agent_script), + ], + cwd=Path(__file__).parent, + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + except Exception as e: + console.print(f"[red]❌ Failed to start service manager: {e}[/red]") + log_file.close() + return False + + log_file.close() + _SERVICE_MANAGER_PID.write_text(str(proc.pid)) + console.print( + f"[green]✅ Service manager started (PID {proc.pid}, port {env['SERVICE_MANAGER_PORT']})[/green]" + ) + return True + + +def _stop_service_manager(): + """Stop the service manager agent process.""" + if _service_manager_managed(): + console.print( + "[dim]🛠 Service manager is a systemd service — left running " + "(use 'systemctl --user stop chronicle-service-manager' to stop)[/dim]" + ) + return + if not _SERVICE_MANAGER_PID.exists(): + _remove_advertised_services() + return + + try: + pid = int(_SERVICE_MANAGER_PID.read_text().strip()) + os.killpg(pid, signal.SIGTERM) + for _ in range(10): + try: + os.kill(pid, 0) + time.sleep(0.5) + except OSError: + break + console.print(f"[green]✅ Service manager stopped (PID {pid})[/green]") + except (ValueError, OSError): + console.print("[dim]Service manager already stopped[/dim]") + finally: + _SERVICE_MANAGER_PID.unlink(missing_ok=True) + # The node agent was the advertiser — clear the local manifest on stop. + _remove_advertised_services() + + +# --- systemd user-service integration (auto-start agents on boot) --- +# +# The service manager and discovery agents are native host processes, not +# containers, so a plain ``./start.sh`` launch does not survive a reboot the way +# Docker's restart policy revives the containers. Optionally install them as +# systemd *user* services (with linger enabled) so they come back on boot. The +# unit's ExecStart re-invokes ``services.py run`` — a foreground runner +# that reuses the same token/advertise setup and then ``exec``s the agent, so +# systemd tracks the agent process directly. + +_REPO_ROOT = Path(__file__).resolve().parent +_SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" + +# unit name -> per-unit systemd config. Keys: +# subcmd services.py subcommand for ExecStart +# description human description (Unit/Description=) +# type systemd service Type (default "exec") +# restart Restart= value (omit for oneshot) +# remain_after_exit RemainAfterExit=yes (oneshot that should read as "active") +# timeout_start_sec TimeoutStartSec= (oneshot stack `up` can be slow) +# after list of After= ordering deps +# enable_now enable --now on install (True) vs enable for boot only (False) +_SYSTEMD_UNITS = { + "chronicle-service-manager": { + "subcmd": "manager run", + "description": "Chronicle node agent (WebUI control + Tailnet service advertising)", + "type": "exec", + "restart": "always", + "restart_sec": 5, + "enable_now": True, + }, + # Boot persistence for the container stack. Rootless Podman is daemonless, so + # unlike Docker nothing re-applies `restart:` policies after a reboot — this + # oneshot runs the same `services.py start --all` that ./start.sh uses to bring + # the enabled stacks (per config.yml) back up. Ordered after the node agent; + # enabled for boot only (install does not kick off a full stack `up`). + "chronicle-stack": { + "subcmd": "start --all", + "description": "Chronicle container stack (start enabled services on boot)", + "type": "oneshot", + "remain_after_exit": True, + "timeout_start_sec": 900, + "after": ["chronicle-service-manager.service"], + "enable_now": False, + }, +} + +_SYSTEMD_STATES = { + "running", + "degraded", + "starting", + "initializing", + "maintenance", + "stopping", +} + + +def _systemctl_user(*args, capture=True): + """Run ``systemctl --user`` and return the CompletedProcess.""" + return subprocess.run( + ["systemctl", "--user", *args], + capture_output=capture, + text=True, + ) + + +def _systemd_user_available() -> bool: + """True if a systemd *user* instance is reachable. + + Not the case on hosts without systemd, or on WSL without ``systemd=true`` + in /etc/wsl.conf (the user bus is then unavailable). + """ + if shutil.which("systemctl") is None: + return False + try: + result = subprocess.run( + ["systemctl", "--user", "is-system-running"], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return False + return (result.stdout or "").strip() in _SYSTEMD_STATES + + +def _unit_enabled(unit: str) -> bool: + if shutil.which("systemctl") is None: + return False + return _systemctl_user("is-enabled", unit).returncode == 0 + + +def _unit_active(unit: str) -> bool: + if shutil.which("systemctl") is None: + return False + return _systemctl_user("is-active", unit).returncode == 0 + + +def _service_manager_managed() -> bool: + return _unit_enabled("chronicle-service-manager") + + +def _write_systemd_unit(unit: str) -> Path: + cfg = _SYSTEMD_UNITS[unit] + uv_path = shutil.which("uv") or "uv" + # A systemd user unit gets a minimal PATH (no ~/.local/bin), so neither uv + # nor binaries the agents shell out to (e.g. tailscale, podman-compose) would + # resolve. Pin a sane PATH that includes uv's own directory. + uv_dir = str(Path(uv_path).parent) + unit_path_env = ":".join( + [ + uv_dir, + str(Path.home() / ".local" / "bin"), + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", + ] + ) + _SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) + + unit_lines = [f"Description={cfg['description']}"] + for after in cfg.get("after", []): + unit_lines.append(f"After={after}") + + service_lines = [ + f"Type={cfg.get('type', 'exec')}", + f"WorkingDirectory={_REPO_ROOT}", + f"Environment=PATH={unit_path_env}", + f"ExecStart={uv_path} run --with-requirements setup-requirements.txt " + f"python {_REPO_ROOT / 'services.py'} {cfg['subcmd']}", + ] + if cfg.get("remain_after_exit"): + service_lines.append("RemainAfterExit=yes") + if cfg.get("restart"): + service_lines.append(f"Restart={cfg['restart']}") + service_lines.append(f"RestartSec={cfg.get('restart_sec', 5)}") + if cfg.get("timeout_start_sec") is not None: + service_lines.append(f"TimeoutStartSec={cfg['timeout_start_sec']}") + + content = ( + "[Unit]\n" + + "\n".join(unit_lines) + + "\n\n[Service]\n" + + "\n".join(service_lines) + + "\n\n[Install]\nWantedBy=default.target\n" + ) + unit_path = _SYSTEMD_USER_DIR / f"{unit}.service" + unit_path.write_text(content) + return unit_path + + +def _install_systemd_unit(unit: str) -> bool: + """Write, enable and start a single user unit. Assumes systemd is available.""" + # Stop any background (Popen) instance first so the unit can bind the port. + if unit == "chronicle-service-manager": + _stop_service_manager() + _cleanup_legacy_discovery() + + path = _write_systemd_unit(unit) + # Keep the user instance (and thus the unit) alive after logout / reboot. + subprocess.run(["loginctl", "enable-linger"], capture_output=True, text=True) + _systemctl_user("daemon-reload") + # The stack oneshot is boot-only: enabling it should register it for boot, not + # trigger a full `start --all` as a side effect of install (./start.sh owns the + # running stack). The agent enables --now so it comes up immediately. + enable_now = _SYSTEMD_UNITS[unit].get("enable_now", True) + enable_args = ["enable", "--now", unit] if enable_now else ["enable", unit] + result = _systemctl_user(*enable_args, capture=False) + if result.returncode != 0: + console.print(f"[red]❌ Failed to enable {unit}[/red]") + return False + suffix = "& started" if enable_now else "(will start on boot)" + console.print(f"[green]✅ Installed {suffix} {unit} (systemd user service)[/green]") + console.print(f"[dim] Unit: {path}[/dim]") + return True + + +def _uninstall_systemd_unit(unit: str) -> bool: + if shutil.which("systemctl") is None: + console.print("[dim]systemctl not found — nothing to uninstall[/dim]") + return False + _systemctl_user("disable", "--now", unit, capture=False) + (_SYSTEMD_USER_DIR / f"{unit}.service").unlink(missing_ok=True) + _systemctl_user("daemon-reload") + console.print(f"[green]✅ Removed {unit} systemd user service[/green]") + return True + + +def _print_systemd_unavailable_help(): + console.print( + "[yellow]⚠️ No systemd user instance available — cannot install services.[/yellow]" + ) + console.print( + "[dim] On WSL, enable it: add a '[boot]' section with 'systemd=true' to " + "/etc/wsl.conf, run 'wsl --shutdown', then reopen the terminal.[/dim]" + ) + + +def install_systemd_agents(units=None) -> bool: + """Install the given agent units (default: all) as systemd user services.""" + units = units or list(_SYSTEMD_UNITS) + if not _systemd_user_available(): + _print_systemd_unavailable_help() + return False + ok = True + for unit in units: + ok = _install_systemd_unit(unit) and ok + if ok: + console.print( + "[dim]These agents will now auto-start on boot. Manage them with " + "'systemctl --user status/stop/restart '.[/dim]" + ) + return ok + + +def uninstall_systemd_agents(units=None) -> bool: + units = units or list(_SYSTEMD_UNITS) + ok = True + for unit in units: + ok = _uninstall_systemd_unit(unit) and ok + return ok + + +def _cleanup_legacy_discovery() -> None: + """Remove the old standalone discovery agent, now folded into the node agent. + + Older installs ran a separate ``chronicle-discovery`` systemd user unit and/or + a native ``edge/.discovery-agent.pid`` process. The node agent (service + manager) now does the advertising, so a leftover discovery agent would + double-advertise — and its unit's ExecStart (``services.py discovery run``) no + longer exists, so it would crash-loop. Disable + remove it. Idempotent. + """ + unit = "chronicle-discovery" + unit_file = _SYSTEMD_USER_DIR / f"{unit}.service" + if shutil.which("systemctl") and (_unit_enabled(unit) or unit_file.exists()): + _systemctl_user("disable", "--now", unit) + unit_file.unlink(missing_ok=True) + _systemctl_user("daemon-reload") + console.print("[dim]🧹 Removed legacy chronicle-discovery systemd unit[/dim]") + + pid_file = Path(__file__).parent / "edge" / ".discovery-agent.pid" + if pid_file.exists(): + try: + pid = int(pid_file.read_text().strip()) + os.killpg(pid, signal.SIGTERM) + console.print(f"[dim]🧹 Stopped legacy discovery agent (PID {pid})[/dim]") + except (ValueError, OSError): + pass + pid_file.unlink(missing_ok=True) + + +def _service_manager_exec(): + """Foreground exec of the service manager agent (for systemd ExecStart).""" + agent_script = _REPO_ROOT / "edge" / "service_manager.py" + if not agent_script.exists(): + console.print("[red]❌ edge/service_manager.py not found[/red]") + sys.exit(1) + os.environ["SERVICE_MANAGER_TOKEN"] = _ensure_service_manager_token() + os.environ.setdefault("SERVICE_MANAGER_PORT", _SERVICE_MANAGER_PORT) + os.chdir(_REPO_ROOT) + # Absolute uv path: a systemd user unit's PATH does not include ~/.local/bin. + uv = shutil.which("uv") or "uv" + os.execv( + uv, + [ + uv, + "run", + "--with-requirements", + "setup-requirements.txt", + "--with", + "fastapi", + "--with", + "uvicorn", + "python", + str(agent_script), + ], + ) + + +# --- Claude remote-control session (control Claude Code from your phone) --- +# +# `claude remote-control` runs a persistent server that accepts multiple sessions +# you spawn from the Claude mobile app / claude.ai/code. It is an interactive TUI, +# so it needs a pty — we run it inside a detached tmux session on a dedicated +# socket (attach at the desktop with `tmux -L chronicle-rc attach -t +# chronicle-rc`). Persistence is via a systemd user +# unit that runs a supervisor wrapper (Type=simple, Restart=always) so it both +# survives reboot and auto-restarts if the server dies, with a WebUI start/stop +# toggle. + +_CLAUDE_RC_UNIT = "chronicle-remote-control" +_CLAUDE_RC_SESSION = os.environ.get("CLAUDE_RC_SESSION", "chronicle-rc") +# Run on a DEDICATED tmux socket, never the user's default one. The tmux server +# spawns inside the systemd unit's cgroup, so a stop/restart (KillMode=control- +# group) tears down that whole server. On the shared default socket that would +# also kill the user's own working sessions; on a private socket it only affects +# remote-control. Attach with `tmux -L chronicle-rc attach -t chronicle-rc`. +_CLAUDE_RC_SOCKET = os.environ.get("CLAUDE_RC_SOCKET", "chronicle-rc") + + +def _rc_tmux_base() -> list[str]: + """tmux argv prefix pinned to the remote-control's private socket.""" + return [shutil.which("tmux") or "tmux", "-L", _CLAUDE_RC_SOCKET] + + +def _claude_rc_dir() -> Path: + """Directory the remote-control server (and its spawned sessions) runs in.""" + return Path(os.environ.get("CLAUDE_RC_DIR") or _REPO_ROOT) + + +def _claude_rc_name() -> str: + """Session name shown in claude.ai/code (defaults to the hostname).""" + return os.environ.get("CLAUDE_RC_NAME") or os.uname().nodename + + +def _claude_rc_command() -> list[str]: + """The `claude remote-control` argv (same-dir spawn, default permission mode).""" + claude = shutil.which("claude") or "claude" + return [ + claude, + "remote-control", + "--spawn=same-dir", + "--permission-mode=default", + "--name", + _claude_rc_name(), + ] + + +def _tmux_session_running(session: str) -> bool: + if shutil.which("tmux") is None: + return False + return ( + subprocess.run( + [*_rc_tmux_base(), "has-session", "-t", session], + capture_output=True, + text=True, + ).returncode + == 0 + ) + + +def _remote_control_managed() -> bool: + return _unit_enabled(_CLAUDE_RC_UNIT) + + +def remote_control_status() -> dict: + """Current state of the Claude remote-control session.""" + return { + "running": _tmux_session_running(_CLAUDE_RC_SESSION), + "managed": _remote_control_managed(), + "session": _CLAUDE_RC_SESSION, + "dir": str(_claude_rc_dir()), + "name": _claude_rc_name(), + "tmux_available": shutil.which("tmux") is not None, + "claude_available": shutil.which("claude") is not None, + } + + +def _start_remote_control_tmux() -> bool: + """Launch the remote-control server in a detached tmux session (idempotent).""" + if shutil.which("tmux") is None: + console.print( + "[red]❌ tmux not found — install tmux to run remote-control[/red]" + ) + return False + if shutil.which("claude") is None: + console.print( + "[red]❌ claude CLI not found — install Claude Code and log in first[/red]" + ) + return False + if _tmux_session_running(_CLAUDE_RC_SESSION): + console.print( + f"[dim]📱 Claude remote-control already running (tmux: {_CLAUDE_RC_SESSION})[/dim]" + ) + return True + rc_dir = _claude_rc_dir() + # Pass the claude command as one string so tmux's shell runs it; when it exits + # the session ends, so `has-session` faithfully reflects whether it is alive. + cmd = " ".join(shlex.quote(part) for part in _claude_rc_command()) + result = subprocess.run( + [ + *_rc_tmux_base(), + "new-session", + "-d", + "-s", + _CLAUDE_RC_SESSION, + "-c", + str(rc_dir), + cmd, + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + console.print( + f"[red]❌ Failed to start remote-control: {result.stderr.strip()}[/red]" + ) + return False + console.print( + f"[green]✅ Claude remote-control started in {rc_dir} " + f"(tmux: {_CLAUDE_RC_SESSION}, name: {_claude_rc_name()})[/green]" + ) + console.print( + "[dim] Spawn sessions from the Claude mobile app / claude.ai/code → Code tab.[/dim]" + ) + return True + + +def _stop_remote_control_tmux() -> bool: + if not _tmux_session_running(_CLAUDE_RC_SESSION): + console.print("[dim]📱 Claude remote-control already stopped[/dim]") + return True + subprocess.run( + [*_rc_tmux_base(), "kill-session", "-t", _CLAUDE_RC_SESSION], + capture_output=True, + text=True, + ) + console.print("[green]✅ Claude remote-control stopped[/green]") + return True + + +def start_remote_control() -> bool: + """Start remote-control, deferring to systemd when it manages the unit.""" + if _remote_control_managed(): + _systemctl_user("start", _CLAUDE_RC_UNIT, capture=False) + console.print( + "[dim]📱 Remote-control managed by systemd (ensured started)[/dim]" + ) + return True + return _start_remote_control_tmux() + + +def stop_remote_control() -> bool: + if _remote_control_managed(): + _systemctl_user("stop", _CLAUDE_RC_UNIT, capture=False) + console.print("[dim]📱 Remote-control (systemd) stopped[/dim]") + return True + return _stop_remote_control_tmux() + + +def _write_remote_control_supervisor() -> Path: + """Write the supervisor wrapper the systemd unit runs as its main process. + + The wrapper starts the detached tmux session (which gives the TUI its pty) + and then *blocks* polling ``has-session``, so it stays alive exactly as long + as the ``claude remote-control`` process does. When that process dies the + session ends, the wrapper exits, and ``Restart=always`` brings it back. A + plain ``tmux new-session -d`` could not be supervised this way: it returns 0 + immediately, so systemd lost track of the real process and never restarted + it when it died. + """ + tmux = shutil.which("tmux") or "/usr/bin/tmux" + rc_dir = _claude_rc_dir() + cmd_line = shlex.join(_claude_rc_command()) + _SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) + script_path = _SYSTEMD_USER_DIR / f"{_CLAUDE_RC_UNIT}.sh" + # `tmux -L `: a PRIVATE server, so killing this unit's cgroup never + # touches the user's default-socket sessions. + script_path.write_text( + f"""#!/bin/sh +# Auto-generated by services.py (_write_remote_control_supervisor). Do not edit. +set -u +SESSION={shlex.quote(_CLAUDE_RC_SESSION)} +# tmux pinned to a dedicated socket (-L) so this never disturbs other tmux servers. +TMUX="{shlex.quote(tmux)} -L {shlex.quote(_CLAUDE_RC_SOCKET)}" +# Clear any stale session of this name, then start fresh. +$TMUX kill-session -t "$SESSION" 2>/dev/null || true +$TMUX new-session -d -s "$SESSION" -c {shlex.quote(str(rc_dir))} {cmd_line} || exit 1 +# Block while the remote-control TUI is alive; exit (-> systemd Restart) once it dies. +while $TMUX has-session -t "$SESSION" 2>/dev/null; do + sleep 5 +done +""" + ) + script_path.chmod(0o755) + return script_path + + +def _write_remote_control_unit() -> Path: + """Write the chronicle-remote-control systemd user unit (supervised tmux).""" + tmux = shutil.which("tmux") or "/usr/bin/tmux" + rc_dir = _claude_rc_dir() + script_path = _write_remote_control_supervisor() + uv_dir = str(Path(shutil.which("uv") or "uv").parent) + unit_path_env = ":".join( + [ + uv_dir, + str(Path.home() / ".local" / "bin"), + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", + ] + ) + _SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) + unit_path = _SYSTEMD_USER_DIR / f"{_CLAUDE_RC_UNIT}.service" + # Type=simple: the supervisor wrapper is the tracked main process and lives as + # long as the tmux session does, so Restart=always genuinely restarts the + # remote-control server whenever it dies (crash, network drop, claude update). + # StartLimitIntervalSec=0 disables the start-rate cap so it never gives up. + unit_path.write_text( + f"""[Unit] +Description=Chronicle Claude remote-control session (control Claude Code from your phone) +StartLimitIntervalSec=0 + +[Service] +Type=simple +WorkingDirectory={rc_dir} +Environment=PATH={unit_path_env} +ExecStart=/bin/sh {script_path} +ExecStop={tmux} -L {_CLAUDE_RC_SOCKET} kill-session -t {_CLAUDE_RC_SESSION} +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target +""" + ) + return unit_path + + +def install_remote_control() -> bool: + """Install + start the remote-control session as a systemd user service.""" + if shutil.which("claude") is None: + console.print( + "[red]❌ claude CLI not found — install Claude Code and log in first[/red]" + ) + return False + if shutil.which("tmux") is None: + console.print("[red]❌ tmux not found — install tmux first[/red]") + return False + if not _systemd_user_available(): + _print_systemd_unavailable_help() + return False + # Drop any manual tmux instance so the unit owns the session name. + _stop_remote_control_tmux() + path = _write_remote_control_unit() + subprocess.run(["loginctl", "enable-linger"], capture_output=True, text=True) + _systemctl_user("daemon-reload") + result = _systemctl_user("enable", "--now", _CLAUDE_RC_UNIT, capture=False) + if result.returncode != 0: + console.print(f"[red]❌ Failed to enable {_CLAUDE_RC_UNIT}[/red]") + return False + console.print( + f"[green]✅ Installed & started {_CLAUDE_RC_UNIT} (systemd user service)[/green]" + ) + console.print(f"[dim] Unit: {path}[/dim]") + console.print( + f"[dim] Sessions run in {_claude_rc_dir()}; spawn them from the Claude app " + "(Code tab). Override dir/name with CLAUDE_RC_DIR / CLAUDE_RC_NAME.[/dim]" + ) + return True + + +def uninstall_remote_control() -> bool: + if shutil.which("systemctl") is None: + console.print("[dim]systemctl not found — nothing to uninstall[/dim]") + return False + _systemctl_user("disable", "--now", _CLAUDE_RC_UNIT, capture=False) + (_SYSTEMD_USER_DIR / f"{_CLAUDE_RC_UNIT}.service").unlink(missing_ok=True) + _systemctl_user("daemon-reload") + console.print(f"[green]✅ Removed {_CLAUDE_RC_UNIT} systemd user service[/green]") + return True + + +def start_services(services, build=False, force_recreate=False): """Start specified services""" console.print(f"🚀 [bold]Starting {len(services)} services...[/bold]") @@ -419,28 +1709,39 @@ def start_services(services, build=False): if service_name not in SERVICES: console.print(f"[red]❌ Unknown service: {service_name}[/red]") continue - + if service_name == "langfuse" and not _ensure_langfuse_env(): console.print("[yellow]⚠️ LangFuse not configured, skipping[/yellow]") continue - if not check_service_configured(service_name): - console.print(f"[yellow]⚠️ {service_name} not configured, skipping[/yellow]") + if not check_service_enabled(service_name): + console.print( + f"[yellow]⚠️ {service_name} not configured, skipping[/yellow]" + ) continue - + console.print(f"\n🔧 Starting {service_name}...") - if run_compose_command(service_name, 'up', build): + if run_compose_command(service_name, "up", build, force_recreate): console.print(f"[green]✅ {service_name} started[/green]") success_count += 1 else: console.print(f"[red]❌ Failed to start {service_name}[/red]") - - console.print(f"\n[green]🎉 {success_count}/{len(services)} services started successfully[/green]") + + console.print( + f"\n[green]🎉 {success_count}/{len(services)} services started successfully[/green]" + ) + + # Start the node agent (WebUI control + Tailnet advertising) on any start. + # It advertises this node's enabled services regardless of whether the + # backend runs here, so service-only nodes (e.g. a GPU/RPi box) advertise too. + _start_service_manager() # Show access URLs if backend was started - if 'backend' in services and check_service_configured('backend'): + if "backend" in services and check_service_enabled("backend"): backend_env = _get_backend_env_path() - https_enabled = (read_env_value(backend_env, "HTTPS_ENABLED") or "").lower() == "true" + https_enabled = ( + read_env_value(backend_env, "HTTPS_ENABLED") or "" + ).lower() == "true" server_ip = read_env_value(backend_env, "SERVER_IP") or "" if https_enabled and server_ip: @@ -459,14 +1760,27 @@ def start_services(services, build=False): console.print(f" API: {api_url}") # Show LangFuse prompt management tip if langfuse was started - if 'langfuse' in services and check_service_configured('langfuse'): - langfuse_url = "http://localhost:3002/project/chronicle/prompts" + if "langfuse" in services and check_service_enabled("langfuse"): + backend_env = _get_backend_env_path() + langfuse_host = read_env_value(backend_env, "SERVER_IP") or "localhost" + langfuse_url = f"http://{langfuse_host}:3002/project/chronicle/prompts" console.print(f" Prompt Mgmt: {langfuse_url}") -def stop_services(services): - """Stop specified services""" + +def stop_services(services, stop_manager=False): + """Stop specified services. + + The service manager agent is only stopped on a full ``stop --all`` — + otherwise it stays up so individual services can be restarted from the UI. + """ console.print(f"🛑 [bold]Stopping {len(services)} services...[/bold]") + # The node agent (advertiser + control) is decoupled from the backend — it + # only stops on a full ``stop --all`` so individual services can still be + # restarted from the UI and advertising survives a backend-only stop. + if stop_manager: + _stop_service_manager() + success_count = 0 for service_name in services: if service_name not in SERVICES: @@ -474,22 +1788,30 @@ def stop_services(services): continue console.print(f"\n🔧 Stopping {service_name}...") - if run_compose_command(service_name, 'down'): + if run_compose_command(service_name, "down"): console.print(f"[green]✅ {service_name} stopped[/green]") success_count += 1 else: console.print(f"[red]❌ Failed to stop {service_name}[/red]") - console.print(f"\n[green]🎉 {success_count}/{len(services)} services stopped successfully[/green]") + console.print( + f"\n[green]🎉 {success_count}/{len(services)} services stopped successfully[/green]" + ) + def restart_services(services, recreate=False): """Restart specified services""" console.print(f"🔄 [bold]Restarting {len(services)} services...[/bold]") if recreate: - console.print("[dim]Using down + up to recreate containers (fixes WSL2 bind mount issues)[/dim]\n") + console.print( + "[dim]Using down + up to recreate containers (fixes WSL2 bind mount issues)[/dim]\n" + ) else: - console.print("[dim]Quick restart (use --recreate to fix bind mount issues)[/dim]\n") + console.print( + "[dim]Recreating containers in place (picks up .env/config + mounted code; " + "use --recreate for a full down+up)[/dim]\n" + ) success_count = 0 for service_name in services: @@ -497,149 +1819,353 @@ def restart_services(services, recreate=False): console.print(f"[red]❌ Unknown service: {service_name}[/red]") continue - if not check_service_configured(service_name): - console.print(f"[yellow]⚠️ {service_name} not configured, skipping[/yellow]") + if not check_service_enabled(service_name): + console.print( + f"[yellow]⚠️ {service_name} not configured, skipping[/yellow]" + ) continue console.print(f"\n🔧 Restarting {service_name}...") if recreate: # Full recreation: down + up (fixes bind mount issues) - if not run_compose_command(service_name, 'down'): + if not run_compose_command(service_name, "down"): console.print(f"[red]❌ Failed to stop {service_name}[/red]") continue - if run_compose_command(service_name, 'up'): + if run_compose_command(service_name, "up"): console.print(f"[green]✅ {service_name} restarted[/green]") success_count += 1 else: console.print(f"[red]❌ Failed to start {service_name}[/red]") else: - # Quick restart: docker compose restart - if run_compose_command(service_name, 'restart'): + # Recreate containers in place. NOT `compose restart`: that doesn't re-read + # .env/config, and podman-compose's `restart` is flaky — it silently leaves + # some containers (e.g. a slow-to-SIGTERM backend) untouched. `up + # --force-recreate` reliably recreates every container in the project and + # picks up env/config + volume-mounted code changes. The service-manager + # agent already restarts via down+up for the same reason. + if run_compose_command(service_name, "up", force_recreate=True): console.print(f"[green]✅ {service_name} restarted[/green]") success_count += 1 else: console.print(f"[red]❌ Failed to restart {service_name}[/red]") - console.print(f"\n[green]🎉 {success_count}/{len(services)} services restarted successfully[/green]") + console.print( + f"\n[green]🎉 {success_count}/{len(services)} services restarted successfully[/green]" + ) + + # Ensure the node agent (control + advertising) is running + _start_service_manager() + def show_status(): """Show status of all services""" console.print("📊 [bold]Service Status:[/bold]\n") - + table = Table() table.add_column("Service", style="cyan") table.add_column("Configured", justify="center") + table.add_column("Running", justify="center") table.add_column("Description", style="dim") table.add_column("Ports", style="green") - + for service_name, service_info in SERVICES.items(): - configured = "✅" if check_service_configured(service_name) else "❌" - ports = ", ".join(service_info['ports']) + configured = "✅" if check_service_enabled(service_name) else "❌" + ports = ", ".join(service_info["ports"]) + + # Check runtime health + status, detail = check_service_health(service_name) + if status == "healthy": + running = "[green]✅ healthy[/green]" + elif status == "partial": + running = f"[yellow]⚠ partial[/yellow] [dim]({detail})[/dim]" + elif status == "unhealthy": + running = "[red]⚠ unhealthy[/red]" + else: + running = "[dim]— stopped[/dim]" + table.add_row( - service_name, - configured, - service_info['description'], - ports + service_name, configured, running, service_info["description"], ports ) - + console.print(table) - + + # Node agent status (control + Tailnet advertising) + if _service_manager_managed(): + state = "running" if _unit_active("chronicle-service-manager") else "stopped" + console.print( + f"[green]🛠 Service manager {state} (systemd user service, port {_SERVICE_MANAGER_PORT})[/green]" + ) + elif _service_manager_running(): + pid = int(_SERVICE_MANAGER_PID.read_text().strip()) + console.print( + f"[green]🛠 Service manager running (PID {pid}, port {_SERVICE_MANAGER_PORT})[/green]" + ) + else: + console.print("[dim]🛠 Service manager not running[/dim]") + + # Stack-on-boot oneshot (Podman has no daemon to revive containers on reboot) + if _unit_enabled("chronicle-stack"): + console.print( + "[green]🔁 Stack auto-start on boot enabled (systemd user service)[/green]" + ) + console.print("\n💡 [dim]Use './start.sh' to start all configured services[/dim]") + def main(): parser = argparse.ArgumentParser(description="Chronicle Service Management") - subparsers = parser.add_subparsers(dest='command', help='Available commands') - + subparsers = parser.add_subparsers(dest="command", help="Available commands") + # Start command - start_parser = subparsers.add_parser('start', help='Start services') - start_parser.add_argument('services', nargs='*', - help='Services to start: backend, speaker-recognition, asr-services, openmemory-mcp (or use --all)') - start_parser.add_argument('--all', action='store_true', help='Start all configured services') - start_parser.add_argument('--build', action='store_true', help='Build images before starting') - + start_parser = subparsers.add_parser("start", help="Start services") + start_parser.add_argument( + "services", + nargs="*", + help="Services to start: backend, speaker-recognition, asr-services (or use --all)", + ) + start_parser.add_argument( + "--all", action="store_true", help="Start all configured services" + ) + start_parser.add_argument( + "--build", action="store_true", help="Build images before starting" + ) + start_parser.add_argument( + "--force-recreate", + action="store_true", + help="Force recreate containers even if unchanged", + ) + start_parser.add_argument( + "--use-prebuilt", + metavar="TAG", + help="Use prebuilt images from GHCR (or custom registry via CHRONICLE_REGISTRY env var)", + ) + # Stop command - stop_parser = subparsers.add_parser('stop', help='Stop services') - stop_parser.add_argument('services', nargs='*', - help='Services to stop: backend, speaker-recognition, asr-services, openmemory-mcp (or use --all)') - stop_parser.add_argument('--all', action='store_true', help='Stop all services') + stop_parser = subparsers.add_parser("stop", help="Stop services") + stop_parser.add_argument( + "services", + nargs="*", + help="Services to stop: backend, speaker-recognition, asr-services (or use --all)", + ) + stop_parser.add_argument("--all", action="store_true", help="Stop all services") # Restart command - restart_parser = subparsers.add_parser('restart', help='Restart services') - restart_parser.add_argument('services', nargs='*', - help='Services to restart: backend, speaker-recognition, asr-services, openmemory-mcp (or use --all)') - restart_parser.add_argument('--all', action='store_true', help='Restart all services') - restart_parser.add_argument('--recreate', action='store_true', - help='Recreate containers (down + up) instead of quick restart - fixes WSL2 bind mount issues') + restart_parser = subparsers.add_parser("restart", help="Restart services") + restart_parser.add_argument( + "services", + nargs="*", + help="Services to restart: backend, speaker-recognition, asr-services (or use --all)", + ) + restart_parser.add_argument( + "--all", action="store_true", help="Restart all services" + ) + restart_parser.add_argument( + "--recreate", + action="store_true", + help="Recreate containers (down + up) instead of quick restart - fixes WSL2 bind mount issues", + ) # Status command - subparsers.add_parser('status', help='Show service status') - + subparsers.add_parser("status", help="Show service status") + + # Update command — move the git checkout and restart services from it + update_parser = subparsers.add_parser( + "update", help="Update this node's code and restart its services" + ) + update_parser.add_argument( + "--check", + action="store_true", + help="Only check whether an update is available", + ) + update_parser.add_argument( + "--tag", + metavar="REF", + help="Update to a specific tag/ref (default: upstream branch, else latest release tag)", + ) + update_parser.add_argument( + "--prebuilt", + metavar="TAG", + help="Use prebuilt images at TAG instead of building locally", + ) + update_parser.add_argument( + "--no-restart", + action="store_true", + help="Move the checkout only; don't restart services", + ) + + # Service manager agent command + manager_parser = subparsers.add_parser( + "manager", help="Manage the service manager agent (WebUI start/stop control)" + ) + manager_parser.add_argument( + "manager_action", + choices=["start", "stop", "restart", "run", "install", "uninstall"], + help="Agent action ('run' = foreground, for systemd; 'install'/'uninstall' = systemd user service)", + ) + + # Claude remote-control session command + rc_parser = subparsers.add_parser( + "remote-control", + help="Manage the Claude remote-control session (control Claude Code from your phone)", + ) + rc_parser.add_argument( + "remote_control_action", + choices=["start", "stop", "restart", "status", "install", "uninstall"], + help="Action ('install'/'uninstall' = systemd user service for boot persistence)", + ) + args = parser.parse_args() - + if not args.command: show_status() return - - if args.command == 'status': + + if args.command == "status": show_status() - - elif args.command == 'start': + + elif args.command == "start": if args.all: services = [ - s for s in SERVICES.keys() - if check_service_configured(s) + s + for s in SERVICES.keys() + if check_service_enabled(s) or (s == "langfuse" and _langfuse_enabled_in_backend()) ] elif args.services: # Validate service names invalid_services = [s for s in args.services if s not in SERVICES] if invalid_services: - console.print(f"[red]❌ Invalid service names: {', '.join(invalid_services)}[/red]") + console.print( + f"[red]❌ Invalid service names: {', '.join(invalid_services)}[/red]" + ) console.print(f"Available services: {', '.join(SERVICES.keys())}") return services = args.services else: - console.print("[red]❌ No services specified. Use --all or specify service names.[/red]") + console.print( + "[red]❌ No services specified. Use --all or specify service names.[/red]" + ) return - - start_services(services, args.build) - - elif args.command == 'stop': + + if args.use_prebuilt: + if os.environ.get("CHRONICLE_REGISTRY"): + registry = os.environ["CHRONICLE_REGISTRY"] + elif os.environ.get("DOCKERHUB_USERNAME"): + registry = f"{os.environ['DOCKERHUB_USERNAME']}/" + else: + registry = "ghcr.io/simpleopensoftware/" + os.environ["CHRONICLE_REGISTRY"] = registry + os.environ["CHRONICLE_TAG"] = args.use_prebuilt + console.print( + f"[cyan]ℹ️ Using prebuilt images: {registry}*:{args.use_prebuilt}[/cyan]" + ) + build_flag = False + else: + build_flag = args.build + + start_services(services, build_flag, args.force_recreate) + + elif args.command == "stop": if args.all: # Only stop configured services (like start --all does) - services = [s for s in SERVICES.keys() if check_service_configured(s)] + services = [s for s in SERVICES.keys() if check_service_enabled(s)] elif args.services: # Validate service names invalid_services = [s for s in args.services if s not in SERVICES] if invalid_services: - console.print(f"[red]❌ Invalid service names: {', '.join(invalid_services)}[/red]") + console.print( + f"[red]❌ Invalid service names: {', '.join(invalid_services)}[/red]" + ) console.print(f"Available services: {', '.join(SERVICES.keys())}") return services = args.services else: - console.print("[red]❌ No services specified. Use --all or specify service names.[/red]") + console.print( + "[red]❌ No services specified. Use --all or specify service names.[/red]" + ) return - stop_services(services) + stop_services(services, stop_manager=args.all) - elif args.command == 'restart': + elif args.command == "restart": if args.all: - services = [s for s in SERVICES.keys() if check_service_configured(s)] + services = [s for s in SERVICES.keys() if check_service_enabled(s)] elif args.services: # Validate service names invalid_services = [s for s in args.services if s not in SERVICES] if invalid_services: - console.print(f"[red]❌ Invalid service names: {', '.join(invalid_services)}[/red]") + console.print( + f"[red]❌ Invalid service names: {', '.join(invalid_services)}[/red]" + ) console.print(f"Available services: {', '.join(SERVICES.keys())}") return services = args.services else: - console.print("[red]❌ No services specified. Use --all or specify service names.[/red]") + console.print( + "[red]❌ No services specified. Use --all or specify service names.[/red]" + ) return restart_services(services, recreate=args.recreate) + elif args.command == "update": + import updates # lazy: updates.py imports this module + + if args.check: + info = updates.check_update(target=args.tag) + cur, tgt = info["current"], info.get("target") + where = f" (branch {cur['branch']})" if cur["branch"] else " (detached)" + console.print(f"Current: {cur['describe']}{where}") + if info.get("error"): + console.print(f"[red]❌ {info['error']}[/red]") + sys.exit(1) + elif info["update_available"]: + console.print( + f"[yellow]⬆️ Update available → {tgt['ref']} ({tgt['commit']})[/yellow]" + ) + else: + console.print("[green]✅ Up to date[/green]") + else: + ok = updates.perform_update( + target=args.tag, + prebuilt=args.prebuilt, + restart_services=not args.no_restart, + ) + sys.exit(0 if ok else 1) + + elif args.command == "manager": + if args.manager_action == "start": + _start_service_manager() + elif args.manager_action == "stop": + _stop_service_manager() + elif args.manager_action == "restart": + _stop_service_manager() + _start_service_manager() + elif args.manager_action == "run": + _service_manager_exec() + elif args.manager_action == "install": + # Installs the node agent AND the stack-on-boot oneshot. + install_systemd_agents() + elif args.manager_action == "uninstall": + uninstall_systemd_agents() + + elif args.command == "remote-control": + if args.remote_control_action == "start": + start_remote_control() + elif args.remote_control_action == "stop": + stop_remote_control() + elif args.remote_control_action == "restart": + stop_remote_control() + start_remote_control() + elif args.remote_control_action == "status": + console.print(remote_control_status()) + elif args.remote_control_action == "install": + install_remote_control() + elif args.remote_control_action == "uninstall": + uninstall_remote_control() + + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/setup-requirements.txt b/setup-requirements.txt index 9d1ab4a6..8f9fd8fe 100644 --- a/setup-requirements.txt +++ b/setup-requirements.txt @@ -4,4 +4,5 @@ python-dotenv>=1.0.0 requests>=2.31.0 pyyaml>=6.0.0 ruamel-yaml>=0.18.0 -omegaconf>=2.3.0 \ No newline at end of file +omegaconf>=2.3.0 +minidisc-python>=0.1.0 diff --git a/setup_utils.py b/setup_utils.py index 8a906c7b..c3bde887 100644 --- a/setup_utils.py +++ b/setup_utils.py @@ -7,9 +7,12 @@ import getpass import json +import os import re import secrets +import shutil import subprocess +import sys from pathlib import Path from typing import List, Optional, Tuple @@ -40,6 +43,48 @@ def read_env_value(env_file_path: str, key: str) -> Optional[str]: return value if value else None +def resolve_ingest_config( + search_paths: list, + host: str = "host.docker.internal", + default_port: str = "8000", +) -> Tuple[Optional[str], Optional[str]]: + """Resolve the cross-service System-Errors ingest URL + token for a sidecar. + + Sidecar services (ASR, speaker-recognition) push their ERROR/CRITICAL logs to the + backend's ``POST /api/admin/system-events/ingest`` so failures surface on the admin + "System Errors" page instead of being buried in container logs. This sources the + backend address + auth token the same way other shared secrets are sourced: from + the backend .env (canonical hub on a main machine) or the repo-root .env (per-node + store), in the given order. + + The token prefers a dedicated ``SYSTEM_EVENT_INGEST_TOKEN`` and falls back to + ``SERVICE_MANAGER_TOKEN`` (which the backend accepts as the ingest fallback). + + Returns ``(ingest_url, ingest_token)``. Both are ``None`` when no backend config is + found locally (e.g. a remote service node with no backend .env) — the reporter is + opt-in and stays a no-op until both are set, so callers should only write non-None + values and leave the keys untouched otherwise. + + Args: + search_paths: .env paths to search, in priority order. + host: Hostname the sidecar uses to reach the backend (default reaches the + host gateway from inside a container). + default_port: Backend HTTP port to use when ``BACKEND_PUBLIC_PORT`` is absent. + + Example: + >>> url, token = resolve_ingest_config(["../../backends/advanced/.env", "../../.env"]) + """ + for path in search_paths: + token = read_env_value(path, "SYSTEM_EVENT_INGEST_TOKEN") or read_env_value( + path, "SERVICE_MANAGER_TOKEN" + ) + if token: + port = read_env_value(path, "BACKEND_PUBLIC_PORT") or default_port + url = f"http://{host}:{port}/api/admin/system-events/ingest" + return url, token + return None, None + + def is_placeholder(value: str, *placeholder_variants: str) -> bool: """ Check if a value is a placeholder. @@ -63,10 +108,10 @@ def is_placeholder(value: str, *placeholder_variants: str) -> bool: return True # Normalize by replacing hyphens with underscores - normalized_value = value.replace('-', '_').lower() + normalized_value = value.replace("-", "_").lower() for placeholder in placeholder_variants: - normalized_placeholder = placeholder.replace('-', '_').lower() + normalized_placeholder = placeholder.replace("-", "_").lower() if normalized_value == normalized_placeholder: return True @@ -126,9 +171,7 @@ def prompt_value(prompt_text: str, default: str = "") -> str: def prompt_password( - prompt_text: str, - min_length: int = 8, - allow_generated: bool = False + prompt_text: str, min_length: int = 8, allow_generated: bool = False ) -> str: """ Prompt user for a password (hidden input). @@ -171,7 +214,7 @@ def prompt_with_existing_masked( is_password: bool = False, default: str = "", env_file_path: Optional[str] = None, - env_key: Optional[str] = None + env_key: Optional[str] = None, ) -> str: """ Prompt for a value, showing masked existing value if present. @@ -228,15 +271,21 @@ def prompt_with_existing_masked( existing_value = read_env_value(env_file_path, env_key) # Check if we have a valid existing value (not a placeholder) - has_valid_existing = existing_value and not is_placeholder(existing_value, *placeholders) + has_valid_existing = existing_value and not is_placeholder( + existing_value, *placeholders + ) if has_valid_existing: # Show masked value with option to reuse if is_password: masked = mask_value(existing_value) - display_prompt = f"{prompt_text} ({masked}) [press Enter to reuse, or enter new]" + display_prompt = ( + f"{prompt_text} ({masked}) [press Enter to reuse, or enter new]" + ) else: - display_prompt = f"{prompt_text} ({existing_value}) [press Enter to reuse, or enter new]" + display_prompt = ( + f"{prompt_text} ({existing_value}) [press Enter to reuse, or enter new]" + ) if is_password: user_input = prompt_password(display_prompt, min_length=0) @@ -255,11 +304,12 @@ def prompt_with_existing_masked( # Convenience functions for common patterns + def prompt_api_key( service_name: str, env_file_path: str = ".env", env_key: Optional[str] = None, - placeholders: Optional[List[str]] = None + placeholders: Optional[List[str]] = None, ) -> str: """ Convenience function for prompting API keys. @@ -278,9 +328,9 @@ def prompt_api_key( """ env_key = env_key or f"{service_name.upper().replace(' ', '_')}_API_KEY" placeholders = placeholders or [ - 'your-api-key-here', - 'your_api_key_here', - f'your-{service_name.lower()}-key-here' + "your-api-key-here", + "your_api_key_here", + f"your-{service_name.lower()}-key-here", ] return prompt_with_existing_masked( @@ -288,7 +338,7 @@ def prompt_api_key( env_file_path=env_file_path, env_key=env_key, placeholders=placeholders, - is_password=True + is_password=True, ) @@ -296,7 +346,7 @@ def prompt_token( service_name: str, env_file_path: str = ".env", env_key: Optional[str] = None, - placeholders: Optional[List[str]] = None + placeholders: Optional[List[str]] = None, ) -> str: """ Convenience function for prompting authentication tokens. @@ -315,9 +365,9 @@ def prompt_token( """ env_key = env_key or f"{service_name.upper().replace(' ', '_')}_TOKEN" placeholders = placeholders or [ - 'your-token-here', - 'your_token_here', - f'your-{service_name.lower()}-token-here' + "your-token-here", + "your_token_here", + f"your-{service_name.lower()}-token-here", ] return prompt_with_existing_masked( @@ -325,7 +375,7 @@ def prompt_token( env_file_path=env_file_path, env_key=env_key, placeholders=placeholders, - is_password=True + is_password=True, ) @@ -344,10 +394,7 @@ def detect_tailscale_info() -> Tuple[Optional[str], Optional[str]]: # Get MagicDNS name from tailscale status --json try: result = subprocess.run( - ["tailscale", "status", "--json"], - capture_output=True, - text=True, - timeout=5 + ["tailscale", "status", "--json"], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: status = json.loads(result.stdout) @@ -361,10 +408,7 @@ def detect_tailscale_info() -> Tuple[Optional[str], Optional[str]]: # Get IPv4 address as fallback try: result = subprocess.run( - ["tailscale", "ip", "-4"], - capture_output=True, - text=True, - timeout=5 + ["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: ip = result.stdout.strip() @@ -374,6 +418,214 @@ def detect_tailscale_info() -> Tuple[Optional[str], Optional[str]]: return dns_name, ip +def generate_tailscale_certs(certs_dir: str) -> bool: + """ + Generate trusted TLS certificates via Tailscale. + + Uses `tailscale cert` to obtain certs signed by the Tailscale CA, which are + automatically trusted on devices in the same tailnet. Tries without sudo first + (works when the Tailscale operator is set to the current user via + `tailscale set --operator=$USER`); falls back to `sudo` otherwise. + + Args: + certs_dir: Directory to write server.crt and server.key into. + + Returns: + True if certificates were generated successfully, False otherwise. + """ + dns_name, _ = detect_tailscale_info() + if not dns_name: + return False + + certs_path = Path(certs_dir) + certs_path.mkdir(parents=True, exist_ok=True) + + cert_file = certs_path / "server.crt" + key_file = certs_path / "server.key" + + cert_cmd = [ + "tailscale", + "cert", + "--cert-file", + str(cert_file), + "--key-file", + str(key_file), + dns_name, + ] + + try: + # Try without sudo first (operator configured). Fall back to non-interactive + # sudo (-n avoids hanging on a password prompt in unattended contexts). + result = subprocess.run(cert_cmd, capture_output=True, text=True, timeout=30) + used_sudo = False + if result.returncode != 0: + result = subprocess.run( + ["sudo", "-n", *cert_cmd], capture_output=True, text=True, timeout=30 + ) + used_sudo = True + if result.returncode != 0: + return False + + if used_sudo: + # sudo wrote the files as root; fix ownership so Docker (and our user) can read them + uid = os.getuid() + gid = os.getgid() + subprocess.run( + ["sudo", "-n", "chown", f"{uid}:{gid}", str(cert_file), str(key_file)], + capture_output=True, + timeout=10, + ) + return True + except (subprocess.SubprocessError, FileNotFoundError, OSError): + return False + + +def cert_needs_renewal(certs_dir: str, within_days: int = 21) -> bool: + """ + Check whether the TLS cert in certs_dir is missing or expiring soon. + + Cheap, local-only check (no network): inspects the cert file's expiry via + `openssl x509 -checkend`, which exits 0 if the cert is valid beyond the window + and non-zero if it expires within it (or is already expired). + + Args: + certs_dir: Directory containing server.crt. + within_days: Treat the cert as needing renewal if it expires within this many days. + + Returns: + True if the cert is missing or expires within `within_days`, False otherwise. + """ + cert_file = Path(certs_dir) / "server.crt" + if not cert_file.exists(): + return True + + try: + result = subprocess.run( + [ + "openssl", + "x509", + "-checkend", + str(within_days * 86400), + "-noout", + "-in", + str(cert_file), + ], + capture_output=True, + timeout=10, + ) + return result.returncode != 0 + except (subprocess.SubprocessError, FileNotFoundError, OSError): + # If expiry can't be determined, err toward renewing. + return True + + +def ensure_tailscale_cert(certs_dir: str, within_days: int = 21) -> Optional[bool]: + """ + Renew the Tailscale TLS cert only if it is missing or near expiry. + + No-op in the common case: just checks the local cert file's expiry and returns + without contacting Tailscale unless renewal is actually due. This keeps the + expensive `tailscale cert` call (and Let's Encrypt issuance) to roughly once + per certificate lifetime regardless of how often it is invoked. + + Args: + certs_dir: Directory containing/receiving server.crt and server.key. + within_days: Renew if the cert expires within this many days. + + Returns: + None if no renewal was needed, True if renewed successfully, + False if renewal was needed but failed. + """ + if not cert_needs_renewal(certs_dir, within_days): + return None + return generate_tailscale_certs(certs_dir) + + +def tailscale_socket_path() -> Optional[str]: + """ + Return the path to the local tailscaled Unix socket if present, else None. + + Used to decide whether Caddy can manage the Tailscale TLS cert itself (socket + mounted into the container) or whether we must fall back to a host-issued cert + file (e.g. Docker Desktop on macOS, where the socket isn't reachable from the VM). + """ + for path in ( + "/var/run/tailscale/tailscaled.sock", + "/run/tailscale/tailscaled.sock", + ): + if Path(path).exists(): + return path + return None + + +def tailscaled_enabled_at_boot() -> Optional[bool]: + """ + Whether the tailscaled systemd unit is enabled to start on boot. + + Returns: + True — `systemctl is-enabled tailscaled` reports enabled (it'll come back + after a reboot). + False — the unit exists but is disabled/static (started manually only; + won't survive a reboot — the classic "Tailscale gone after reboot"). + None — can't tell: not Linux, no systemd/systemctl, or no tailscaled unit + (e.g. Tailscale.app on macOS, or a non-systemd init). Nothing to offer. + """ + if sys.platform != "linux" or shutil.which("systemctl") is None: + return None + try: + result = subprocess.run( + ["systemctl", "is-enabled", "tailscaled"], + capture_output=True, + text=True, + timeout=5, + ) + except (subprocess.SubprocessError, OSError): + return None + state = result.stdout.strip() + if state == "enabled": + return True + # "disabled" / "static" → won't auto-start. Unknown unit → systemctl prints + # nothing to stdout (message goes to stderr, rc=1) → treat as "can't tell". + if state in ("disabled", "static"): + return False + return None + + +def enable_tailscaled_at_boot() -> bool: + """ + Enable (and start) the tailscaled unit so it survives reboots. + + Runs `sudo systemctl enable --now tailscaled`. Returns True on success. Uses sudo + because enabling a system unit needs root; the user may be prompted for a password. + """ + try: + return ( + subprocess.run( + ["sudo", "systemctl", "enable", "--now", "tailscaled"] + ).returncode + == 0 + ) + except OSError: + return False + + +def decide_cert_mode(server_address: str) -> str: + """ + Decide how the HTTPS certificate is managed for the given server address. + + Returns: + "static" — host issues the cert file and Caddy serves it. Only for a Tailscale + (*.ts.net) address when no tailscaled socket is available to mount into + Caddy (e.g. Docker Desktop on macOS). Renewed by the services.py startup hook. + "caddy" — Caddy obtains and auto-renews the cert itself: *.ts.net via the + mounted tailscaled socket, a real domain via Let's Encrypt, and an IP or + localhost via Caddy's internal CA. No host cert file, no renewal cron. + """ + if server_address.endswith(".ts.net") and not tailscale_socket_path(): + return "static" + return "caddy" + + def detect_cuda_version(default: str = "cu126") -> str: """ Detect system CUDA version from nvidia-smi output. @@ -384,25 +636,20 @@ def detect_cuda_version(default: str = "cu126") -> str: default: Default CUDA version if detection fails (default: "cu126") Returns: - PyTorch CUDA version string: "cu121", "cu126", or "cu128" + PyTorch CUDA version string: "cu126" or "cu128" """ try: result = subprocess.run( - ["nvidia-smi"], - capture_output=True, - text=True, - timeout=5 + ["nvidia-smi"], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: - match = re.search(r'CUDA Version:\s*(\d+)\.(\d+)', result.stdout) + match = re.search(r"CUDA Version:\s*(\d+)\.(\d+)", result.stdout) if match: major, minor = int(match.group(1)), int(match.group(2)) if (major, minor) >= (12, 8): return "cu128" - elif (major, minor) >= (12, 6): - return "cu126" - elif (major, minor) >= (12, 1): - return "cu121" + # cu126 is the lowest supported build (torch>=2.7 dropped cu121) + return "cu126" except (subprocess.SubprocessError, FileNotFoundError): pass return default diff --git a/skaffold.env.template b/skaffold.env.template index 1e4c54a3..e566b242 100644 --- a/skaffold.env.template +++ b/skaffold.env.template @@ -12,7 +12,7 @@ REGISTRY=192.168.1.42:32000 # ============================================================================= # KUBERNETES NAMESPACE CONFIGURATION # ============================================================================= -# Namespace for infrastructure services (MongoDB, Qdrant) +# Namespace for infrastructure services (MongoDB, FalkorDB, Redis) INFRASTRUCTURE_NAMESPACE=root # Namespace for application services (Backend, WebUI) APPLICATION_NAMESPACE=friend-lite diff --git a/skaffold.yaml b/skaffold.yaml index 718e61a3..2740fd8e 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -99,13 +99,6 @@ profiles: persistence.size: "10Gi" persistence.storageClass: "openebs-hostpath" service.nameOverride: "mongodb" - - - name: qdrant - chartPath: backends/charts/qdrant - namespace: "{{.INFRASTRUCTURE_NAMESPACE}}" - setValueTemplates: - persistence.size: "10Gi" - persistence.storageClass: "openebs-hostpath" - name: redis remoteChart: oci://registry-1.docker.io/bitnamicharts/redis @@ -150,7 +143,6 @@ profiles: image.tag: "{{.IMAGE_TAG_advanced_backend}}" # Override specific Kubernetes-specific values (not in env file) env.MONGODB_URI: "mongodb://mongodb.{{.INFRASTRUCTURE_NAMESPACE}}.svc.cluster.local:27017/chronicle" - env.QDRANT_BASE_URL: "qdrant.{{.INFRASTRUCTURE_NAMESPACE}}.svc.cluster.local" env.REDIS_URL: "redis://redis-master.{{.INFRASTRUCTURE_NAMESPACE}}.svc.cluster.local:6379/0" persistence.storageClass: "openebs-hostpath" persistence.size: "10Gi" @@ -187,7 +179,7 @@ profiles: - image: chronicle-backend-test context: backends/advanced docker: - dockerfile: Dockerfile + dockerfile: Dockerfile - image: webui-test context: backends/advanced/webui docker: @@ -195,9 +187,9 @@ profiles: deploy: docker: - images: [chronicle-backend-test, webui-test, mongo-test, qdrant-test, redis-test] - - + images: [chronicle-backend-test, webui-test, mongo-test, redis-test] + + - name: speaker-recognition-gtx1070 build: @@ -229,5 +221,3 @@ profiles: global.sharedModels.useHostPath: "true" global.sharedModels.size: "20Gi" global.sharedModels.hostPath: "/shared/models" - - diff --git a/start.sh b/start.sh index b01ef87a..6f064929 100755 --- a/start.sh +++ b/start.sh @@ -1 +1,13 @@ -uv run --with-requirements setup-requirements.txt python services.py start --all "$@" +#!/bin/bash +source "$(dirname "$0")/scripts/check_uv.sh" + +# If the first argument is a known subcommand, pass it directly to services.py +# instead of prepending "start --all". This lets "./start.sh status" work correctly. +case "${1:-}" in + status|stop|restart) + uv run --with-requirements setup-requirements.txt python services.py "$@" + ;; + *) + uv run --with-requirements setup-requirements.txt python services.py start --all "$@" + ;; +esac diff --git a/status.py b/status.py index 90322cbc..e72c8cc2 100644 --- a/status.py +++ b/status.py @@ -5,31 +5,24 @@ """ import argparse -import subprocess -import sys import json -import requests +import subprocess from pathlib import Path -from typing import Dict, List, Any, Optional +from typing import Any, Dict, List, Optional -from rich import print as rprint +import requests from rich.console import Console from rich.table import Table -from rich.panel import Panel -from rich.live import Live -from rich.layout import Layout -from dotenv import dotenv_values # Import service definitions from services.py -from services import SERVICES, check_service_configured +from services import SERVICES, check_service_enabled, compose_ps_json, container_engine console = Console() # Health check endpoints HEALTH_ENDPOINTS = { - 'backend': 'http://localhost:8000/health', - 'speaker-recognition': 'http://localhost:8085/health', - 'openmemory-mcp': 'http://localhost:8765/docs', # No health endpoint, check docs + "backend": "http://localhost:8000/health", + "speaker-recognition": "http://localhost:8085/health", } @@ -39,17 +32,18 @@ def get_restart_counts(container_names: List[str]) -> Dict[str, int]: return {} try: result = subprocess.run( - ['docker', 'inspect', '--format', '{{.Name}} {{.RestartCount}}'] + container_names, + [container_engine(), "inspect", "--format", "{{.Name}} {{.RestartCount}}"] + + container_names, capture_output=True, text=True, - timeout=10 + timeout=10, ) counts = {} - for line in result.stdout.strip().split('\n'): + for line in result.stdout.strip().split("\n"): if line.strip(): - parts = line.strip().rsplit(' ', 1) + parts = line.strip().rsplit(" ", 1) if len(parts) == 2: - name = parts[0].lstrip('/') + name = parts[0].lstrip("/") try: counts[name] = int(parts[1]) except ValueError: @@ -62,74 +56,49 @@ def get_restart_counts(container_names: List[str]) -> Dict[str, int]: def get_container_status(service_name: str) -> Dict[str, Any]: """Get Docker container status for a service""" service = SERVICES[service_name] - service_path = Path(service['path']) + service_path = Path(service["path"]) if not service_path.exists(): - return {'status': 'not_found', 'containers': []} + return {"status": "not_found", "containers": []} try: - # Get container status using docker compose ps - # Only check containers from active profiles (excludes inactive profile services) - cmd = ['docker', 'compose', 'ps', '--format', 'json'] + # Get container status (engine-aware: docker compose ps vs podman ps by + # compose project label). Only active-profile containers are reported. + raw_containers = compose_ps_json(service_path) - result = subprocess.run( - cmd, - cwd=service_path, - capture_output=True, - text=True, - timeout=10 - ) - - if result.returncode != 0: - return {'status': 'error', 'containers': [], 'error': result.stderr} - - # Parse JSON output (one JSON object per line) containers = [] - for line in result.stdout.strip().split('\n'): - if line: - try: - container = json.loads(line) - container_name = container.get('Name', 'unknown') - - # Skip test containers - they're not part of production services - if '-test-' in container_name.lower(): - continue - - containers.append({ - 'name': container_name, - 'state': container.get('State', 'unknown'), - 'status': container.get('Status', 'unknown'), - 'health': container.get('Health', 'none') - }) - except json.JSONDecodeError: - continue + for container in raw_containers: + # Skip test containers - they're not part of production services + if "-test-" in container["name"].lower(): + continue + containers.append(container) if not containers: - return {'status': 'stopped', 'containers': []} + return {"status": "stopped", "containers": []} # Fetch restart counts via docker inspect - container_names = [c['name'] for c in containers] + container_names = [c["name"] for c in containers] restart_counts = get_restart_counts(container_names) for container in containers: - container['restart_count'] = restart_counts.get(container['name'], 0) + container["restart_count"] = restart_counts.get(container["name"], 0) # Determine overall status - all_running = all(c['state'] == 'running' for c in containers) - any_running = any(c['state'] == 'running' for c in containers) + all_running = all(c["state"] == "running" for c in containers) + any_running = any(c["state"] == "running" for c in containers) if all_running: - status = 'running' + status = "running" elif any_running: - status = 'partial' + status = "partial" else: - status = 'stopped' + status = "stopped" - return {'status': status, 'containers': containers} + return {"status": status, "containers": containers} except subprocess.TimeoutExpired: - return {'status': 'timeout', 'containers': []} + return {"status": "timeout", "containers": []} except Exception as e: - return {'status': 'error', 'containers': [], 'error': str(e)} + return {"status": "error", "containers": [], "error": str(e)} def check_http_health(url: str, timeout: int = 5) -> Dict[str, Any]: @@ -141,28 +110,28 @@ def check_http_health(url: str, timeout: int = 5) -> Dict[str, Any]: # Try to parse JSON response try: data = response.json() - return {'healthy': True, 'status_code': 200, 'data': data} + return {"healthy": True, "status_code": 200, "data": data} except json.JSONDecodeError: - return {'healthy': True, 'status_code': 200, 'data': None} + return {"healthy": True, "status_code": 200, "data": None} else: - return {'healthy': False, 'status_code': response.status_code, 'data': None} + return {"healthy": False, "status_code": response.status_code, "data": None} except requests.exceptions.ConnectionError: - return {'healthy': False, 'error': 'Connection refused'} + return {"healthy": False, "error": "Connection refused"} except requests.exceptions.Timeout: - return {'healthy': False, 'error': 'Timeout'} + return {"healthy": False, "error": "Timeout"} except Exception as e: - return {'healthy': False, 'error': str(e)} + return {"healthy": False, "error": str(e)} def get_service_health(service_name: str) -> Dict[str, Any]: """Get comprehensive health status for a service""" # Check if configured - if not check_service_configured(service_name): + if not check_service_enabled(service_name): return { - 'configured': False, - 'container_status': 'not_configured', - 'health': None + "configured": False, + "container_status": "not_configured", + "health": None, } # Get container status @@ -175,10 +144,10 @@ def get_service_health(service_name: str) -> Dict[str, Any]: health_check = check_http_health(url) return { - 'configured': True, - 'container_status': container_info['status'], - 'containers': container_info.get('containers', []), - 'health': health_check + "configured": True, + "container_status": container_info["status"], + "containers": container_info.get("containers", []), + "health": health_check, } @@ -189,15 +158,15 @@ def get_backend_worker_health() -> Optional[Dict[str, Any]]: This catches internal worker crash loops that Docker restart counts miss. """ try: - response = requests.get('http://localhost:8000/health', timeout=5) + response = requests.get("http://localhost:8000/health", timeout=5) if response.status_code == 200: data = response.json() - redis_info = data.get('services', {}).get('redis', {}) + redis_info = data.get("services", {}).get("redis", {}) return { - 'worker_count': redis_info.get('worker_count', 0), - 'active_workers': redis_info.get('active_workers', 0), - 'idle_workers': redis_info.get('idle_workers', 0), - 'queues': redis_info.get('queues', {}), + "worker_count": redis_info.get("worker_count", 0), + "active_workers": redis_info.get("active_workers", 0), + "idle_workers": redis_info.get("idle_workers", 0), + "queues": redis_info.get("queues", {}), } except Exception: pass @@ -220,28 +189,30 @@ def show_quick_status(): status = get_service_health(service_name) # Config status - config_icon = "✅" if status['configured'] else "❌" + config_icon = "✅" if status["configured"] else "❌" # Container status - if not status['configured']: + if not status["configured"]: container_icon = "⚪" - elif status['container_status'] == 'running': + elif status["container_status"] == "running": container_icon = "🟢" - elif status['container_status'] == 'partial': + elif status["container_status"] == "partial": container_icon = "🟡" - elif status['container_status'] == 'stopped': + elif status["container_status"] == "stopped": container_icon = "🔴" - elif status['container_status'] == 'not_found': + elif status["container_status"] == "not_found": container_icon = "⚪" - elif status['container_status'] in ['error', 'timeout']: + elif status["container_status"] in ["error", "timeout"]: container_icon = "⚫" else: # Unknown status - log it for debugging container_icon = "⚫" # Restart count - total_restarts = sum(c.get('restart_count', 0) for c in status.get('containers', [])) - if not status['configured'] or not status.get('containers'): + total_restarts = sum( + c.get("restart_count", 0) for c in status.get("containers", []) + ) + if not status["configured"] or not status.get("containers"): restart_text = "⚪" elif total_restarts > 0: restart_text = f"[bold red]⚠️ {total_restarts}[/bold red]" @@ -249,9 +220,9 @@ def show_quick_status(): restart_text = "[green]0[/green]" # Health status - if status['health'] is None: + if status["health"] is None: health_icon = "⚪" - elif status['health'].get('healthy'): + elif status["health"].get("healthy"): health_icon = "✅" else: health_icon = "❌" @@ -262,7 +233,7 @@ def show_quick_status(): container_icon, restart_text, health_icon, - service_info['description'] + service_info["description"], ) console.print(table) @@ -270,20 +241,32 @@ def show_quick_status(): # Worker health note (from backend /health endpoint) worker_health = get_backend_worker_health() if worker_health is not None: - wc = worker_health['worker_count'] - active = worker_health['active_workers'] - total_failed = sum(q.get('failed_count', 0) for q in worker_health['queues'].values()) + wc = worker_health["worker_count"] + active = worker_health["active_workers"] + total_failed = sum( + q.get("failed_count", 0) for q in worker_health["queues"].values() + ) if wc == 0: - console.print("\n[bold red] ❌ RQ Workers: 0 registered — workers may be crash-looping. Check: docker compose logs workers[/bold red]") + console.print( + "\n[bold red] ❌ RQ Workers: 0 registered — workers may be crash-looping. Check: docker compose logs workers[/bold red]" + ) elif total_failed > 0: - console.print(f"\n [yellow]⚠️ RQ Workers: {wc} registered ({active} active), {total_failed} failed job(s) in queues[/yellow]") + console.print( + f"\n [yellow]⚠️ RQ Workers: {wc} registered ({active} active), {total_failed} failed job(s) in queues[/yellow]" + ) else: - console.print(f"\n [green]✅ RQ Workers: {wc} registered ({active} active)[/green]") + console.print( + f"\n [green]✅ RQ Workers: {wc} registered ({active} active)[/green]" + ) # Legend console.print("\n[dim]Legend:[/dim]") - console.print("[dim] Containers: 🟢 Running | 🟡 Partial | 🔴 Stopped | ⚪ Not Configured | ⚫ Error[/dim]") - console.print("[dim] Restarts: 0 = stable | ⚠️ N = container crashed N times (restart loop)[/dim]") + console.print( + "[dim] Containers: 🟢 Running | 🟡 Partial | 🔴 Stopped | ⚪ Not Configured | ⚫ Error[/dim]" + ) + console.print( + "[dim] Restarts: 0 = stable | ⚠️ N = container crashed N times (restart loop)[/dim]" + ) console.print("[dim] Health: ✅ Healthy | ❌ Unhealthy | ⚪ No Endpoint[/dim]") @@ -296,7 +279,7 @@ def show_detailed_status(): status = get_service_health(service_name) # Service header - if status['configured']: + if status["configured"]: header = f"📦 {service_name.upper()}" else: header = f"📦 {service_name.upper()} (Not Configured)" @@ -304,81 +287,110 @@ def show_detailed_status(): console.print(f"\n[bold cyan]{header}[/bold cyan]") console.print(f"[dim]{service_info['description']}[/dim]") - if not status['configured']: + if not status["configured"]: console.print("[yellow] ⚠️ Not configured (no .env file)[/yellow]") continue # Container status console.print(f"\n [bold]Containers:[/bold]") - if status['container_status'] == 'running': + if status["container_status"] == "running": console.print(f" [green]🟢 All containers running[/green]") - elif status['container_status'] == 'partial': + elif status["container_status"] == "partial": console.print(f" [yellow]🟡 Some containers running[/yellow]") - elif status['container_status'] == 'stopped': + elif status["container_status"] == "stopped": console.print(f" [red]🔴 All containers stopped[/red]") else: console.print(f" [red]⚫ Error checking containers[/red]") # Show container details - for container in status.get('containers', []): - state_icon = "🟢" if container['state'] == 'running' else "🔴" - health_status = f" ({container['health']})" if container['health'] != 'none' else "" - restart_count = container.get('restart_count', 0) - restart_info = f" [bold red]⚠️ {restart_count} restarts[/bold red]" if restart_count > 0 else "" - console.print(f" {state_icon} {container['name']}: {container['status']}{health_status}{restart_info}") + for container in status.get("containers", []): + state_icon = "🟢" if container["state"] == "running" else "🔴" + health_status = ( + f" ({container['health']})" if container["health"] != "none" else "" + ) + restart_count = container.get("restart_count", 0) + restart_info = ( + f" [bold red]⚠️ {restart_count} restarts[/bold red]" + if restart_count > 0 + else "" + ) + console.print( + f" {state_icon} {container['name']}: {container['status']}{health_status}{restart_info}" + ) # HTTP Health check - if status['health'] is not None: + if status["health"] is not None: console.print(f"\n [bold]HTTP Health:[/bold]") - if status['health'].get('healthy'): + if status["health"].get("healthy"): console.print(f" [green]✅ Healthy[/green]") # For backend, show detailed health data - if service_name == 'backend' and status['health'].get('data'): - health_data = status['health']['data'] + if service_name == "backend" and status["health"].get("data"): + health_data = status["health"]["data"] # Overall status - overall_status = health_data.get('status', 'unknown') - if overall_status == 'healthy': + overall_status = health_data.get("status", "unknown") + if overall_status == "healthy": console.print(f" Overall: [green]{overall_status}[/green]") - elif overall_status == 'degraded': - console.print(f" Overall: [yellow]{overall_status}[/yellow]") + elif overall_status == "degraded": + console.print( + f" Overall: [yellow]{overall_status}[/yellow]" + ) else: console.print(f" Overall: [red]{overall_status}[/red]") # Critical services - services = health_data.get('services', {}) + services = health_data.get("services", {}) console.print(f"\n [bold]Critical Services:[/bold]") - for svc_name in ['mongodb', 'redis']: + for svc_name in ["mongodb", "redis"]: if svc_name in services: svc = services[svc_name] - if svc.get('healthy'): - console.print(f" [green]✅ {svc_name}: {svc.get('status', 'ok')}[/green]") + if svc.get("healthy"): + console.print( + f" [green]✅ {svc_name}: {svc.get('status', 'ok')}[/green]" + ) else: - console.print(f" [red]❌ {svc_name}: {svc.get('status', 'error')}[/red]") + console.print( + f" [red]❌ {svc_name}: {svc.get('status', 'error')}[/red]" + ) # Optional services console.print(f"\n [bold]Optional Services:[/bold]") - optional_services = ['audioai', 'memory_service', 'speech_to_text', 'speaker_recognition', 'openmemory_mcp'] + optional_services = [ + "audioai", + "memory_service", + "speech_to_text", + "speaker_recognition", + ] for svc_name in optional_services: if svc_name in services: svc = services[svc_name] - if svc.get('healthy'): - console.print(f" [green]✅ {svc_name}: {svc.get('status', 'ok')}[/green]") + if svc.get("healthy"): + console.print( + f" [green]✅ {svc_name}: {svc.get('status', 'ok')}[/green]" + ) else: - console.print(f" [yellow]⚠️ {svc_name}: {svc.get('status', 'degraded')}[/yellow]") + console.print( + f" [yellow]⚠️ {svc_name}: {svc.get('status', 'degraded')}[/yellow]" + ) # Configuration info - config = health_data.get('config', {}) + config = health_data.get("config", {}) if config: console.print(f"\n [bold]Configuration:[/bold]") - console.print(f" LLM: {config.get('llm_provider', 'unknown')} ({config.get('llm_model', 'unknown')})") - console.print(f" Transcription: {config.get('transcription_service', 'unknown')}") - console.print(f" Active Clients: {config.get('active_clients', 0)}") + console.print( + f" LLM: {config.get('llm_provider', 'unknown')} ({config.get('llm_model', 'unknown')})" + ) + console.print( + f" Transcription: {config.get('transcription_service', 'unknown')}" + ) + console.print( + f" Active Clients: {config.get('active_clients', 0)}" + ) else: - error = status['health'].get('error', 'Unknown error') + error = status["health"].get("error", "Unknown error") console.print(f" [red]❌ Unhealthy: {error}[/red]") console.print("") # Spacing @@ -403,19 +415,18 @@ def main(): ./status.sh Show quick status overview ./status.sh --detailed Show detailed health information ./status.sh --json Output status in JSON format - """ + """, ) parser.add_argument( - '--detailed', '-d', - action='store_true', - help='Show detailed health information including backend service breakdown' + "--detailed", + "-d", + action="store_true", + help="Show detailed health information including backend service breakdown", ) parser.add_argument( - '--json', '-j', - action='store_true', - help='Output status in JSON format' + "--json", "-j", action="store_true", help="Output status in JSON format" ) args = parser.parse_args() @@ -427,7 +438,9 @@ def main(): else: show_quick_status() - console.print("\n💡 [dim]Tip: Use './status.sh --detailed' for comprehensive health checks[/dim]\n") + console.print( + "\n💡 [dim]Tip: Use './status.sh --detailed' for comprehensive health checks[/dim]\n" + ) if __name__ == "__main__": diff --git a/status.sh b/status.sh index a66fe459..6ded4c94 100755 --- a/status.sh +++ b/status.sh @@ -1,2 +1,3 @@ #!/bin/bash +source "$(dirname "$0")/scripts/check_uv.sh" uv run --with-requirements setup-requirements.txt python status.py "$@" diff --git a/stop.sh b/stop.sh index 0f49add7..0fc033d4 100755 --- a/stop.sh +++ b/stop.sh @@ -1 +1,3 @@ +#!/bin/bash +source "$(dirname "$0")/scripts/check_uv.sh" uv run --with-requirements setup-requirements.txt python services.py stop --all diff --git a/tests/.env.test b/tests/.env.test index b26c757c..9b7b1d0c 100644 --- a/tests/.env.test +++ b/tests/.env.test @@ -17,4 +17,4 @@ MEMORY_PROVIDER=chronicle # MongoDB Configuration (test environment) MONGODB_URI=mongodb://localhost:27018 -TEST_DB_NAME=test_db \ No newline at end of file +TEST_DB_NAME=test_db diff --git a/tests/DEBUG_GUIDE.md b/tests/DEBUG_GUIDE.md index e13050d4..f3d641b8 100644 --- a/tests/DEBUG_GUIDE.md +++ b/tests/DEBUG_GUIDE.md @@ -213,7 +213,6 @@ uv run ruff check src/ | Workers | `advanced-backend-test-workers-test-1` | `docker logs ` | | MongoDB | `advanced-backend-test-mongo-test-1` | `docker logs ` | | Redis | `advanced-backend-test-redis-test-1` | `docker logs ` | -| Qdrant | `advanced-backend-test-qdrant-test-1` | `docker logs ` | ## Tips for Faster Debugging diff --git a/tests/Makefile b/tests/Makefile index 2992c3a1..6ef60dbf 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -5,8 +5,8 @@ containers-start containers-stop containers-restart containers-rebuild \ containers-start-rebuild containers-clean containers-status containers-logs \ start stop restart rebuild start-rebuild status logs \ - test test-quick test-slow test-sdk test-no-api test-with-api-keys test-all-with-slow-and-sdk clean-all \ - test-asr test-asr-gpu \ + test test-quick test-custom test-slow test-sdk test-no-api test-with-api-keys test-all-with-slow-and-sdk clean-all \ + test-asr test-asr-gpu test-asr-strix-parakeet \ results results-path results-detailed # Default output directory @@ -55,6 +55,12 @@ help: @echo "ASR Tests:" @echo " make test-asr - Run ASR protocol tests (no GPU required)" @echo " make test-asr-gpu - Run ASR GPU tests (requires NVIDIA GPU)" + @echo " make test-asr-strix-parakeet - Run Parakeet Strix Halo ROCm integration test" + @echo "" + @echo "Running Specific Tests:" + @echo " make test-custom T=\"Test Name\" - Run a single test by name" + @echo " make test-custom TAG=audio-streaming - Run tests by tag" + @echo " make test-custom F=integration/foo.robot - Run a specific file" @echo "" @echo "Special Test Tags:" @echo " make test-slow - Run ONLY slow tests (backend restarts)" @@ -213,18 +219,22 @@ logs: containers-logs # ============================================================================ # Full workflow: start containers + run all tests -# If CONFIG is specified and differs from running containers, recreates them +# Auto-detects config mismatch and recreates containers if needed test: - @if docker compose -f ../backends/advanced/docker-compose-test.yml ps chronicle-backend-test 2>/dev/null | grep -q "Up"; then \ - echo "ℹ️ Containers already running"; \ - if [ "$(CONFIG)" != "" ]; then \ - echo "🔄 CONFIG specified - will recreate containers to apply new config"; \ + @RUNNING_CONFIG=$$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \ + $$(docker compose -f ../backends/advanced/docker-compose-test.yml ps -q chronicle-backend-test 2>/dev/null) 2>/dev/null \ + | grep '^CONFIG_FILE=' | cut -d= -f2); \ + if [ -n "$$RUNNING_CONFIG" ]; then \ + if [ "$$RUNNING_CONFIG" != "$(TEST_CONFIG_FILE)" ]; then \ + echo "⚠️ Container config mismatch: running=$$RUNNING_CONFIG expected=$(TEST_CONFIG_FILE)"; \ + echo "🔄 Recreating containers with correct config..."; \ $(MAKE) containers-stop; \ $(MAKE) containers-start; \ else \ - echo "✅ Using existing containers (use CONFIG=... to switch config)"; \ - fi \ + echo "✅ Containers running with correct config: $$RUNNING_CONFIG"; \ + fi; \ else \ + echo "🚀 Starting containers..."; \ $(MAKE) containers-start; \ fi @$(MAKE) all @@ -232,6 +242,14 @@ test: # Quick workflow: run tests on existing containers (ignores CONFIG changes) test-quick: all +# Run specific test by name, tag, or file +# Usage: +# make test-custom T="Chunk Count Increments In Redis Session" +# make test-custom TAG=audio-streaming +# make test-custom F=integration/audio_streaming_integration_tests.robot +test-custom: + @./run-custom.sh $(if $(T),--test "$(T)") $(if $(TAG),--tag $(TAG)) $(if $(F),$(F)) + # Run ONLY slow tests (backend restarts, long timeouts) test-slow: @echo "Running slow tests only..." @@ -321,6 +339,17 @@ test-asr-gpu: --include requires-gpu \ asr +# Run Parakeet ROCm integration test for AMD Strix Halo +test-asr-strix-parakeet: + @echo "Running Parakeet Strix Halo ROCm integration test..." + @echo "Using extras/asr-services/docker-compose-test-strixhalo.yml" + PARAKEET_TEST_COMPOSE_FILE=docker-compose-test-strixhalo.yml \ + PARAKEET_TEST_SERVICE=parakeet-asr-strixhalo-test \ + PARAKEET_SERVICE_URL=http://localhost:8768 \ + PARAKEET_HEALTH_TIMEOUT=420 \ + PARAKEET_REQUEST_TIMEOUT=300 \ + uv run --directory ../extras/asr-services pytest tests/test_parakeet_service.py -v -s + # ============================================================================ # View Test Results # ============================================================================ diff --git a/tests/README.md b/tests/README.md index 89b8882b..b199a2d1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -77,7 +77,6 @@ make logs SERVICE=chronicle-backend-test # Main backend service make logs SERVICE=workers-test # RQ workers make logs SERVICE=mongo-test # MongoDB make logs SERVICE=redis-test # Redis -make logs SERVICE=qdrant-test # Vector database make logs SERVICE=speaker-service-test # Speaker recognition ``` @@ -112,8 +111,6 @@ Test services run on separate ports from production to avoid conflicts: | Backend API | `8001` | `8000` | | MongoDB | `27018` | `27017` | | Redis | `6380` | `6379` | -| Qdrant HTTP | `6337` | `6333` | -| Qdrant gRPC | `6338` | `6334` | **Test Database:** Uses `test_db` database (isolated from production) diff --git a/tests/TESTING_GUIDELINES.md b/tests/TESTING_GUIDELINES.md index 481b8017..9f4f0bad 100644 --- a/tests/TESTING_GUIDELINES.md +++ b/tests/TESTING_GUIDELINES.md @@ -266,7 +266,7 @@ Full Pipeline Integration Test ```bash # Excludes tests tagged with requires-api-keys cd tests -./run-no-api-tests.sh +make test-no-api ``` - Uses `configs/mock-services.yml` - No external API calls @@ -297,7 +297,7 @@ For tests that don't require API keys, use the mock services config: **Features**: - Disables external transcription and LLM services -- Keeps core services operational (MongoDB, Redis, Qdrant) +- Keeps core services operational (MongoDB, Redis, FalkorDB) - No API keys required - Fast test execution @@ -375,7 +375,7 @@ Audio Upload Produces Quality Transcript **Running Tests Locally Without API Keys**: ```bash cd tests -./run-no-api-tests.sh +make test-no-api ``` - Works without any API key configuration - Fast feedback for most development @@ -532,4 +532,4 @@ As we develop more conventions and encounter new patterns, we will add them to t - Continuous integration considerations - Test reporting and metrics - Parallel test execution patterns -- Test data isolation strategies \ No newline at end of file +- Test data isolation strategies diff --git a/tests/TESTING_USER_GUIDE.md b/tests/TESTING_USER_GUIDE.md index 5dc6bf6d..e93c0831 100644 --- a/tests/TESTING_USER_GUIDE.md +++ b/tests/TESTING_USER_GUIDE.md @@ -98,7 +98,7 @@ cd tests/ This script will: 1. Check for required API keys -2. Start all required services (MongoDB, Redis, Qdrant, Backend) +2. Start all required services (MongoDB, Redis, FalkorDB, Backend) 3. Run all Robot Framework tests 4. Generate test reports 5. Clean up (by default) diff --git a/tests/asr/gpu_integration_tests.robot b/tests/asr/gpu_integration_tests.robot index b339bc56..0e064e5d 100644 --- a/tests/asr/gpu_integration_tests.robot +++ b/tests/asr/gpu_integration_tests.robot @@ -26,7 +26,7 @@ ${GPU_ASR_URL} http://localhost:8767 ${TEST_AUDIO_FILE} ${CURDIR}/../test_assets/DIY_Experts_Glass_Blowing_16khz_mono_1min.wav # ASR service configuration ${ASR_SERVICE} transformers-asr -${ASR_MODEL} microsoft/VibeVoice-ASR +${ASR_MODEL} microsoft/VibeVoice-ASR-HF ${ASR_PORT} 8767 *** Keywords *** diff --git a/tests/bin/_engine.sh b/tests/bin/_engine.sh new file mode 100644 index 00000000..3e18b8e0 --- /dev/null +++ b/tests/bin/_engine.sh @@ -0,0 +1,30 @@ +# shellcheck shell=bash +# Resolve the container engine + compose command for the test harness so it runs +# under Podman as well as Docker. Source this from the bin/ scripts: +# +# source "$(dirname "$0")/_engine.sh" +# $COMPOSE -f docker-compose-test.yml up -d # docker compose | podman-compose +# $ENGINE inspect ... # docker | podman +# +# Precedence: CONTAINER_ENGINE / COMPOSE_CMD env → config/config.yml +# container_engine → docker default. Mirrors services.py's engine selection. + +_engine_repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +_engine_config="$_engine_repo_root/config/config.yml" + +if [ -z "${CONTAINER_ENGINE:-}" ] && [ -f "$_engine_config" ]; then + CONTAINER_ENGINE="$(grep -E '^container_engine:' "$_engine_config" \ + | head -1 | awk '{print $2}' | tr -d "\"'")" +fi + +ENGINE="${CONTAINER_ENGINE:-docker}" + +if [ -n "${COMPOSE_CMD:-}" ]; then + COMPOSE="$COMPOSE_CMD" +elif [ "$ENGINE" = "podman" ]; then + COMPOSE="podman-compose" +else + COMPOSE="docker compose" +fi + +export ENGINE COMPOSE diff --git a/tests/bin/clean-containers.sh b/tests/bin/clean-containers.sh index 8a4c748a..9ced936b 100755 --- a/tests/bin/clean-containers.sh +++ b/tests/bin/clean-containers.sh @@ -1,4 +1,5 @@ #!/bin/bash +source "$(dirname "$0")/_engine.sh" # tests/bin/clean-containers.sh # ALWAYS saves logs before removing containers @@ -18,7 +19,7 @@ echo "" # Now safe to remove echo "🗑️ Step 2/2: Removing containers and volumes..." cd "$BACKEND_DIR" -docker compose -f docker-compose-test.yml down -v +$COMPOSE -f docker-compose-test.yml down -v echo "" echo "✅ Cleanup complete!" diff --git a/tests/bin/logs-containers.sh b/tests/bin/logs-containers.sh index fe76a20f..7502256a 100755 --- a/tests/bin/logs-containers.sh +++ b/tests/bin/logs-containers.sh @@ -1,4 +1,5 @@ #!/bin/bash +source "$(dirname "$0")/_engine.sh" # tests/bin/logs-containers.sh # View logs for specific service @@ -14,7 +15,6 @@ if [ -z "$SERVICE" ]; then echo " - workers-test" echo " - mongo-test" echo " - redis-test" - echo " - qdrant-test" echo " - speaker-service-test" echo "" echo "Usage: make containers-logs SERVICE=" @@ -28,4 +28,4 @@ echo "📜 Viewing logs for: $SERVICE" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" -docker compose -f docker-compose-test.yml logs --tail=100 -f "$SERVICE" +$COMPOSE -f docker-compose-test.yml logs --tail=100 -f "$SERVICE" diff --git a/tests/bin/rebuild-containers.sh b/tests/bin/rebuild-containers.sh index 827e544e..2853746e 100755 --- a/tests/bin/rebuild-containers.sh +++ b/tests/bin/rebuild-containers.sh @@ -1,4 +1,5 @@ #!/bin/bash +source "$(dirname "$0")/_engine.sh" # tests/bin/rebuild-containers.sh # Rebuild test container images (does not start containers) @@ -16,7 +17,7 @@ echo "" # Build images echo "🏗️ Building images..." -docker compose -f docker-compose-test.yml build +$COMPOSE -f docker-compose-test.yml build echo "✅ Test container images rebuilt successfully" echo " Run 'make start' to start the containers" diff --git a/tests/bin/restart-containers.sh b/tests/bin/restart-containers.sh index c6d12846..c056b1ce 100755 --- a/tests/bin/restart-containers.sh +++ b/tests/bin/restart-containers.sh @@ -1,4 +1,5 @@ #!/bin/bash +source "$(dirname "$0")/_engine.sh" # tests/bin/restart-containers.sh # Restart test containers without rebuilding @@ -10,7 +11,7 @@ BACKEND_DIR="$SCRIPT_DIR/../../backends/advanced" cd "$BACKEND_DIR" echo "🔄 Restarting test containers..." -docker compose -f docker-compose-test.yml restart +$COMPOSE -f docker-compose-test.yml restart echo "⏳ Waiting for services to be ready..." sleep 5 diff --git a/tests/bin/save-container-logs.sh b/tests/bin/save-container-logs.sh index 14f68be9..3165fae3 100755 --- a/tests/bin/save-container-logs.sh +++ b/tests/bin/save-container-logs.sh @@ -1,6 +1,7 @@ #!/bin/bash +source "$(dirname "$0")/_engine.sh" # tests/bin/save-container-logs.sh -# CRITICAL: Always called before docker compose down -v +# CRITICAL: Always called before $COMPOSE down -v # Saves all container logs to timestamped directory set -e @@ -18,22 +19,22 @@ echo "📝 Saving container logs to logs/$TIMESTAMP/" PROJECT_NAME="backend-test" # Service list (based on docker-compose-test.yml) -SERVICES="chronicle-backend-test workers-test mongo-test redis-test qdrant-test speaker-service-test" +SERVICES="chronicle-backend-test workers-test mongo-test redis-test speaker-service-test" # Save logs for each service for service in $SERVICES; do CONTAINER="${PROJECT_NAME}-${service}-1" echo " - Saving $service logs..." - docker logs "$CONTAINER" > "$LOG_DIR/$service.log" 2>&1 || echo " Warning: Could not save logs for $CONTAINER" + $ENGINE logs "$CONTAINER" > "$LOG_DIR/$service.log" 2>&1 || echo " Warning: Could not save logs for $CONTAINER" done # Save container status echo " - Saving container status..." -docker ps -a --filter "name=$PROJECT_NAME" > "$LOG_DIR/container-status.txt" 2>&1 || true +$ENGINE ps -a --filter "name=$PROJECT_NAME" > "$LOG_DIR/container-status.txt" 2>&1 || true # Save container stats (resource usage) echo " - Saving container stats..." -docker stats --no-stream --no-trunc --filter "name=$PROJECT_NAME" > "$LOG_DIR/container-stats.txt" 2>&1 || true +$ENGINE stats --no-stream --no-trunc --filter "name=$PROJECT_NAME" > "$LOG_DIR/container-stats.txt" 2>&1 || true # Copy test results if they exist if [ -d "$SCRIPT_DIR/../results" ]; then diff --git a/tests/bin/start-containers.sh b/tests/bin/start-containers.sh index a01d8110..233c3aa0 100755 --- a/tests/bin/start-containers.sh +++ b/tests/bin/start-containers.sh @@ -1,4 +1,5 @@ #!/bin/bash +source "$(dirname "$0")/_engine.sh" # tests/bin/start-containers.sh # Start test containers with health checks @@ -42,7 +43,7 @@ fi # Start containers echo "🐳 Starting Docker containers..." -docker compose -f docker-compose-test.yml up -d +$COMPOSE -f docker-compose-test.yml up -d # Wait for services to be healthy echo "⏳ Waiting for services to be healthy..." @@ -87,9 +88,15 @@ echo "🔍 Checking container stability (waiting 5s)..." sleep 5 RESTART_ISSUES="" -for CONTAINER_ID in $(docker compose -f docker-compose-test.yml ps -q); do - NAME=$(docker inspect --format '{{.Name}}' "$CONTAINER_ID" | sed 's/^\///') - RESTART_COUNT=$(docker inspect --format '{{.RestartCount}}' "$CONTAINER_ID") +# `$COMPOSE ps -q` is docker-compatible under docker; under podman-compose it may not +# emit clean IDs, so this supplementary loop must never abort startup (the curl +# health/readiness checks above are the real gate). Tolerate query failures. +for CONTAINER_ID in $($COMPOSE -f docker-compose-test.yml ps -q 2>/dev/null || true); do + NAME=$($ENGINE inspect --format '{{.Name}}' "$CONTAINER_ID" 2>/dev/null | sed 's/^\///') + RESTART_COUNT=$($ENGINE inspect --format '{{.RestartCount}}' "$CONTAINER_ID" 2>/dev/null) + case "$RESTART_COUNT" in + ''|*[!0-9]*) continue ;; # non-numeric / unavailable → skip + esac if [ "$RESTART_COUNT" -gt 0 ]; then RESTART_ISSUES="${RESTART_ISSUES} ⚠️ ${NAME} has restarted ${RESTART_COUNT} times\n" fi @@ -100,7 +107,7 @@ if [ -n "$RESTART_ISSUES" ]; then echo "❌ Container stability check FAILED - restart loops detected:" echo "" echo -e "$RESTART_ISSUES" - echo " Check logs: docker compose -f docker-compose-test.yml logs " + echo " Check logs: $COMPOSE -f docker-compose-test.yml logs " echo " Common causes: missing env vars, import errors, dependency crashes" exit 1 fi @@ -111,4 +118,3 @@ echo "✅ Test containers are running and healthy" echo " Backend: http://localhost:8001" echo " MongoDB: localhost:27018" echo " Redis: localhost:6380" -echo " Qdrant: localhost:6337/6338" diff --git a/tests/bin/start-rebuild-containers.sh b/tests/bin/start-rebuild-containers.sh index 39dbfb1e..8ccce1d5 100755 --- a/tests/bin/start-rebuild-containers.sh +++ b/tests/bin/start-rebuild-containers.sh @@ -1,4 +1,5 @@ #!/bin/bash +source "$(dirname "$0")/_engine.sh" # tests/bin/start-rebuild-containers.sh # Stop, rebuild, and start containers (full sequence for code changes) @@ -27,11 +28,11 @@ fi # Stop containers echo "🛑 Stopping containers..." -docker compose -f docker-compose-test.yml stop +$COMPOSE -f docker-compose-test.yml stop # Rebuild and start echo "🏗️ Rebuilding images..." -docker compose -f docker-compose-test.yml up -d --build +$COMPOSE -f docker-compose-test.yml up -d --build # Flush Redis to clear stale keys from previous test runs. # Redis uses appendonly persistence with a bind mount, so data survives @@ -39,7 +40,7 @@ docker compose -f docker-compose-test.yml up -d --build # failures when the audio persistence job finds a Redis key pointing to # a MongoDB document that no longer exists. echo "🗑️ Flushing Redis for clean test state..." -docker compose -f docker-compose-test.yml exec -T redis-test redis-cli FLUSHALL > /dev/null 2>&1 || true +$COMPOSE -f docker-compose-test.yml exec -T redis-test redis-cli FLUSHALL > /dev/null 2>&1 || true # Wait for services echo "⏳ Waiting for services to be ready..." diff --git a/tests/bin/status-containers.sh b/tests/bin/status-containers.sh index e1311508..42b678f5 100755 --- a/tests/bin/status-containers.sh +++ b/tests/bin/status-containers.sh @@ -1,4 +1,5 @@ #!/bin/bash +source "$(dirname "$0")/_engine.sh" # tests/bin/status-containers.sh # Show container health and status @@ -16,7 +17,7 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━ echo "" # Show container status -docker ps -a --filter "name=$PROJECT_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" +$ENGINE ps -a --filter "name=$PROJECT_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @@ -25,9 +26,9 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━ echo "" echo "🔄 Restart Counts:" HAS_RESTARTS=false -for CONTAINER_ID in $(docker ps -q --filter "name=$PROJECT_NAME" 2>/dev/null); do - NAME=$(docker inspect --format '{{.Name}}' "$CONTAINER_ID" | sed 's/^\///') - RESTART_COUNT=$(docker inspect --format '{{.RestartCount}}' "$CONTAINER_ID") +for CONTAINER_ID in $($ENGINE ps -q --filter "name=$PROJECT_NAME" 2>/dev/null); do + NAME=$($ENGINE inspect --format '{{.Name}}' "$CONTAINER_ID" | sed 's/^\///') + RESTART_COUNT=$($ENGINE inspect --format '{{.RestartCount}}' "$CONTAINER_ID") if [ "$RESTART_COUNT" -gt 0 ]; then echo " ⚠️ ${NAME}: ${RESTART_COUNT} restarts" HAS_RESTARTS=true diff --git a/tests/bin/stop-containers.sh b/tests/bin/stop-containers.sh index 6b893b23..8f40e49a 100755 --- a/tests/bin/stop-containers.sh +++ b/tests/bin/stop-containers.sh @@ -1,4 +1,5 @@ #!/bin/bash +source "$(dirname "$0")/_engine.sh" # tests/bin/stop-containers.sh # Stop test containers (preserves volumes) @@ -10,7 +11,7 @@ BACKEND_DIR="$SCRIPT_DIR/../../backends/advanced" cd "$BACKEND_DIR" echo "🛑 Stopping test containers..." -docker compose -f docker-compose-test.yml stop +$COMPOSE -f docker-compose-test.yml stop echo "✅ Test containers stopped (volumes preserved)" echo " Use 'make start' to restart" diff --git a/tests/browser/browser_auth.robot b/tests/browser/browser_auth.robot index 90820c71..d03c0017 100644 --- a/tests/browser/browser_auth.robot +++ b/tests/browser/browser_auth.robot @@ -18,7 +18,7 @@ Test Browser Can Access Login Page # Use the backend URL from test.env ${backend_url}= Set Variable ${WEB_URL} - + Open Browser ${backend_url}/login chromium Wait For Elements State id=email visible timeout=10s Fill Text id=email ${ADMIN_EMAIL} @@ -29,4 +29,4 @@ Test Browser Can Access Login Page Log Successfully accessed login page and logged in INFO - Close Browser \ No newline at end of file + Close Browser diff --git a/tests/config/plugins.test.yml b/tests/config/plugins.test.yml index 89772a56..36352220 100644 --- a/tests/config/plugins.test.yml +++ b/tests/config/plugins.test.yml @@ -12,3 +12,14 @@ plugins: condition: type: always # Capture all events without filtering db_path: /app/debug/test_plugin_events.db + + button_control: + enabled: true + events: + - button.single_press + - button.double_press + condition: + type: always + # NOTE: only enabled/events/condition are read from this file (orchestration). + # Button->action mapping comes from plugins/button_control/config.yml: + # single_press -> stop_playback, double_press -> close_conversation diff --git a/tests/configs/README.md b/tests/configs/README.md index 0b6ff73d..98df4125 100644 --- a/tests/configs/README.md +++ b/tests/configs/README.md @@ -74,7 +74,6 @@ defaults: llm: provider-llm embedding: provider-embed stt: stt-provider - vector_store: vs-qdrant models: - name: provider-llm diff --git a/tests/configs/deepgram-openai.yml b/tests/configs/deepgram-openai.yml index 9e039a9a..fcbc78ca 100644 --- a/tests/configs/deepgram-openai.yml +++ b/tests/configs/deepgram-openai.yml @@ -6,7 +6,6 @@ defaults: llm: openai-llm stt: stt-deepgram stt_stream: stt-deepgram-stream - vector_store: vs-qdrant memory: extraction: enabled: true @@ -44,16 +43,6 @@ models: model_type: embedding model_url: https://api.openai.com/v1 name: openai-embed -- api_family: qdrant - description: Qdrant vector database - model_params: - collection_name: omi_memories - host: ${oc.env:QDRANT_BASE_URL,qdrant} - port: ${oc.env:QDRANT_PORT,6333} - model_provider: qdrant - model_type: vector_store - model_url: http://${oc.env:QDRANT_BASE_URL,qdrant}:${oc.env:QDRANT_PORT,6333} - name: vs-qdrant - api_family: http api_key: ${oc.env:DEEPGRAM_API_KEY,} description: Deepgram Nova 3 (batch) diff --git a/tests/configs/mock-services.yml b/tests/configs/mock-services.yml index 6db03401..c2e12934 100644 --- a/tests/configs/mock-services.yml +++ b/tests/configs/mock-services.yml @@ -6,7 +6,6 @@ defaults: llm: mock-llm stt: mock-stt stt_stream: mock-stt-stream - vector_store: vs-qdrant memory: extraction: enabled: true @@ -36,16 +35,6 @@ models: model_type: embedding model_url: http://host.docker.internal:11435/v1 name: mock-embed -- api_family: qdrant - description: Qdrant vector database (local) - model_params: - collection_name: omi_memories - host: ${oc.env:QDRANT_BASE_URL,qdrant} - port: ${oc.env:QDRANT_PORT,6333} - model_provider: qdrant - model_type: vector_store - model_url: http://${oc.env:QDRANT_BASE_URL,qdrant}:${oc.env:QDRANT_PORT,6333} - name: vs-qdrant - api_family: mock api_key: mock-key-not-used description: Mock STT for testing (batch) @@ -91,8 +80,9 @@ backend: audio: # Enable always_persist for testing - creates placeholder conversations immediately always_persist_enabled: true - transcription: - use_provider_segments: true + diarization: + # Trust provider segments (mock provider returns pre-built segments) + diarization_source: provider speaker_recognition: enabled: false timeout: 60 diff --git a/tests/configs/mock-transcription-failure.yml b/tests/configs/mock-transcription-failure.yml index f9b03575..3a180132 100644 --- a/tests/configs/mock-transcription-failure.yml +++ b/tests/configs/mock-transcription-failure.yml @@ -6,7 +6,6 @@ defaults: llm: mock-llm stt: stt-deepgram-invalid stt_stream: stt-deepgram-stream-invalid - vector_store: vs-qdrant memory: extraction: enabled: false @@ -35,16 +34,6 @@ models: model_type: embedding model_url: https://api.openai.com/v1 name: mock-embed -- api_family: qdrant - description: Qdrant vector database (local) - model_params: - collection_name: omi_memories - host: ${oc.env:QDRANT_BASE_URL,qdrant} - port: ${oc.env:QDRANT_PORT,6333} - model_provider: qdrant - model_type: vector_store - model_url: http://${oc.env:QDRANT_BASE_URL,qdrant}:${oc.env:QDRANT_PORT,6333} - name: vs-qdrant # Deepgram with invalid API key to trigger transcription failures - api_family: http api_key: invalid-key-for-testing diff --git a/tests/configs/mock-vibevoice.yml b/tests/configs/mock-vibevoice.yml index 7fe67884..c75b565d 100644 --- a/tests/configs/mock-vibevoice.yml +++ b/tests/configs/mock-vibevoice.yml @@ -14,7 +14,6 @@ defaults: embedding: mock-embed stt: stt-vibevoice stt_stream: mock-stt-stream - vector_store: vs-qdrant models: - name: mock-llm @@ -41,17 +40,6 @@ models: embedding_dimensions: 1536 model_output: vector - - name: vs-qdrant - description: Qdrant vector database (local) - model_type: vector_store - model_provider: qdrant - api_family: qdrant - model_url: http://${oc.env:QDRANT_BASE_URL,qdrant}:${oc.env:QDRANT_PORT,6333} - model_params: - host: ${oc.env:QDRANT_BASE_URL,qdrant} - port: ${oc.env:QDRANT_PORT,6333} - collection_name: omi_memories - - name: stt-vibevoice description: Mock VibeVoice ASR with built-in speaker diarization model_type: stt @@ -109,9 +97,9 @@ memory: backend: audio: always_persist_enabled: true - transcription: - # Use segments from provider (VibeVoice provides pre-diarized segments) - use_provider_segments: true + diarization: + # Trust provider segments (VibeVoice provides pre-diarized segments) + diarization_source: provider # Speaker recognition should skip diarization step for VibeVoice # but can still run speaker identification if enrolled speakers exist diff --git a/tests/configs/parakeet-ollama.yml b/tests/configs/parakeet-ollama.yml index 99dd7362..b10cfaa5 100644 --- a/tests/configs/parakeet-ollama.yml +++ b/tests/configs/parakeet-ollama.yml @@ -5,7 +5,6 @@ defaults: llm: local-llm embedding: local-embed stt: stt-parakeet-batch - vector_store: vs-qdrant models: - name: local-llm @@ -32,17 +31,6 @@ models: embedding_dimensions: 768 model_output: vector - - name: vs-qdrant - description: Qdrant vector database - model_type: vector_store - model_provider: qdrant - api_family: qdrant - model_url: http://${oc.env:QDRANT_BASE_URL,qdrant}:${oc.env:QDRANT_PORT,6333} - model_params: - host: ${oc.env:QDRANT_BASE_URL,qdrant} - port: ${oc.env:QDRANT_PORT,6333} - collection_name: omi_memories - - name: stt-parakeet-batch description: Parakeet NeMo ASR (batch) - local offline transcription model_type: stt diff --git a/tests/configs/parakeet-openai.yml b/tests/configs/parakeet-openai.yml index c0d7b40a..8e72b0c7 100644 --- a/tests/configs/parakeet-openai.yml +++ b/tests/configs/parakeet-openai.yml @@ -5,7 +5,6 @@ defaults: llm: openai-llm embedding: openai-embed stt: stt-parakeet-batch - vector_store: vs-qdrant models: - name: openai-llm @@ -32,17 +31,6 @@ models: embedding_dimensions: 1536 model_output: vector - - name: vs-qdrant - description: Qdrant vector database - model_type: vector_store - model_provider: qdrant - api_family: qdrant - model_url: http://${oc.env:QDRANT_BASE_URL,qdrant}:${oc.env:QDRANT_PORT,6333} - model_params: - host: ${oc.env:QDRANT_BASE_URL,qdrant} - port: ${oc.env:QDRANT_PORT,6333} - collection_name: omi_memories - - name: stt-parakeet-batch description: Parakeet NeMo ASR (batch) - local offline transcription model_type: stt diff --git a/tests/configuration/test_llm_custom_provider.robot b/tests/configuration/test_llm_custom_provider.robot new file mode 100644 index 00000000..66d7fa81 --- /dev/null +++ b/tests/configuration/test_llm_custom_provider.robot @@ -0,0 +1,258 @@ +*** Settings *** +Documentation Tests for LLM Custom Provider Setup (ConfigManager) +Library OperatingSystem +Library Collections +Library String +Library ../libs/ConfigTestHelper.py + +*** Keywords *** +Setup Temp Config + [Documentation] Creates a temporary configuration environment + ${random_suffix}= Generate Random String 8 [NUMBERS] + ${temp_path}= Join Path ${OUTPUT DIR} temp_config_${random_suffix} + Create Directory ${temp_path} + + # Create initial default config content + ${defaults}= Create Dictionary llm=openai-llm embedding=openai-embed stt=stt-deepgram + ${model1_params}= Create Dictionary temperature=${0.2} max_tokens=${2000} + ${model1}= Create Dictionary + ... name=openai-llm + ... description=OpenAI GPT-4o-mini + ... model_type=llm + ... model_provider=openai + ... api_family=openai + ... model_name=gpt-4o-mini + ... model_url=https://api.openai.com/v1 + ... api_key=\${oc.env:OPENAI_API_KEY,''} + ... model_params=${model1_params} + ... model_output=json + + ${model2}= Create Dictionary + ... name=local-embed + ... description=Local embeddings via Ollama + ... model_type=embedding + ... model_provider=ollama + ... api_family=openai + ... model_name=nomic-embed-text:latest + ... model_url=http://localhost:11434/v1 + ... api_key=\${oc.env:OPENAI_API_KEY,ollama} + ... embedding_dimensions=${768} + ... model_output=vector + + ${models}= Create List ${model1} ${model2} + ${memory}= Create Dictionary provider=chronicle + ${config}= Create Dictionary defaults=${defaults} models=${models} memory=${memory} + + Create Temp Config Structure ${temp_path} ${config} + Set Test Variable ${TEMP_PATH} ${temp_path} + +Cleanup Temp Config + Remove Directory ${TEMP_PATH} recursive=True + +*** Test Cases *** +Add New Model To Config + [Documentation] add_or_update_model() should append a new model when name doesn't exist. + [Setup] Setup Temp Config + [Teardown] Cleanup Temp Config + + ${params}= Create Dictionary temperature=${0.2} max_tokens=${2000} + ${new_model}= Create Dictionary + ... name=custom-llm + ... description=Custom OpenAI-compatible LLM + ... model_type=llm + ... model_provider=openai + ... api_family=openai + ... model_name=llama-3.1-70b-versatile + ... model_url=https://api.groq.com/openai/v1 + ... api_key=\${oc.env:CUSTOM_LLM_API_KEY,''} + ... model_params=${params} + ... model_output=json + + ${cm}= Get Config Manager Instance ${TEMP_PATH} + Add Model To Config Manager ${cm} ${new_model} + + ${config}= Call Method ${cm} get_full_config + ${models}= Get From Dictionary ${config} models + + ${target_model}= Set Variable ${None} + FOR ${m} IN @{models} + Run Keyword If '${m["name"]}' == 'custom-llm' Set Test Variable ${target_model} ${m} + END + + Should Not Be Equal ${target_model} ${None} + Should Be Equal ${target_model["model_name"]} llama-3.1-70b-versatile + Should Be Equal ${target_model["model_url"]} https://api.groq.com/openai/v1 + Should Be Equal ${target_model["model_type"]} llm + +Update Existing Model + [Documentation] add_or_update_model() should replace an existing model with the same name. + [Setup] Setup Temp Config + [Teardown] Cleanup Temp Config + + ${cm}= Get Config Manager Instance ${TEMP_PATH} + + # First add + ${model_v1}= Create Dictionary name=custom-llm model_type=llm model_name=model-v1 model_url=https://example.com/v1 + Add Model To Config Manager ${cm} ${model_v1} + + # Then update + ${model_v2}= Create Dictionary name=custom-llm model_type=llm model_name=model-v2 model_url=https://example.com/v2 + Add Model To Config Manager ${cm} ${model_v2} + + ${config}= Call Method ${cm} get_full_config + ${models}= Get From Dictionary ${config} models + + ${count}= Set Variable 0 + ${target_model}= Set Variable ${None} + FOR ${m} IN @{models} + IF '${m["name"]}' == 'custom-llm' + Set Test Variable ${target_model} ${m} + ${count}= Evaluate ${count} + 1 + END + END + + Should Be Equal As Integers ${count} 1 + Should Be Equal ${target_model["model_name"]} model-v2 + Should Be Equal ${target_model["model_url"]} https://example.com/v2 + +Add Model To Empty Models List + [Documentation] add_or_update_model() should create models list if it doesn't exist. + [Setup] Setup Temp Config + [Teardown] Cleanup Temp Config + + # Overwrite config with empty models + ${defaults}= Create Dictionary llm=openai-llm + ${empty_config}= Create Dictionary defaults=${defaults} + Create Temp Config Structure ${TEMP_PATH} ${empty_config} + + ${cm}= Get Config Manager Instance ${TEMP_PATH} + ${test_model}= Create Dictionary name=test-model model_type=llm + Add Model To Config Manager ${cm} ${test_model} + + ${config}= Call Method ${cm} get_full_config + Dictionary Should Contain Key ${config} models + ${models}= Get From Dictionary ${config} models + Length Should Be ${models} 1 + Should Be Equal ${models[0]["name"]} test-model + +Custom LLM And Embedding Model Added + [Documentation] Both LLM and embedding models should be created when embedding model is provided. + [Setup] Setup Temp Config + [Teardown] Cleanup Temp Config + + ${cm}= Get Config Manager Instance ${TEMP_PATH} + + ${params}= Create Dictionary temperature=${0.2} max_tokens=${2000} + ${llm_model}= Create Dictionary + ... name=custom-llm + ... model_type=llm + ... model_provider=openai + ... api_family=openai + ... model_name=llama-3.1-70b-versatile + ... model_url=https://api.groq.com/openai/v1 + ... api_key=\${oc.env:CUSTOM_LLM_API_KEY,''} + ... model_params=${params} + ... model_output=json + + ${embed_model}= Create Dictionary + ... name=custom-embed + ... description=Custom OpenAI-compatible embeddings + ... model_type=embedding + ... model_provider=openai + ... api_family=openai + ... model_name=text-embedding-3-small + ... model_url=https://api.groq.com/openai/v1 + ... api_key=\${oc.env:CUSTOM_LLM_API_KEY,''} + ... embedding_dimensions=${1536} + ... model_output=vector + + Add Model To Config Manager ${cm} ${llm_model} + Add Model To Config Manager ${cm} ${embed_model} + + ${config}= Call Method ${cm} get_full_config + ${models}= Get From Dictionary ${config} models + ${model_names}= Create List + FOR ${m} IN @{models} + Append To List ${model_names} ${m["name"]} + END + + List Should Contain Value ${model_names} custom-llm + List Should Contain Value ${model_names} custom-embed + + ${target_embed}= Set Variable ${None} + FOR ${m} IN @{models} + Run Keyword If '${m["name"]}' == 'custom-embed' Set Test Variable ${target_embed} ${m} + END + + Should Be Equal ${target_embed["model_type"]} embedding + Should Be Equal ${target_embed["model_name"]} text-embedding-3-small + Should Be Equal As Integers ${target_embed["embedding_dimensions"]} 1536 + +Custom LLM Without Embedding Falls Back To Local + [Documentation] defaults.embedding should be local-embed when no custom embedding is provided. + [Setup] Setup Temp Config + [Teardown] Cleanup Temp Config + + ${cm}= Get Config Manager Instance ${TEMP_PATH} + + ${llm_model}= Create Dictionary + ... name=custom-llm + ... model_type=llm + ... model_name=some-model + ... model_url=https://api.example.com/v1 + + Add Model To Config Manager ${cm} ${llm_model} + ${defaults_update}= Create Dictionary llm=custom-llm embedding=local-embed + Update Defaults In Config Manager ${cm} ${defaults_update} + + ${defaults}= Call Method ${cm} get_config_defaults + Should Be Equal ${defaults["llm"]} custom-llm + Should Be Equal ${defaults["embedding"]} local-embed + +Custom LLM Updates Defaults With Embedding + [Documentation] defaults.llm and defaults.embedding should be updated correctly with custom embed. + [Setup] Setup Temp Config + [Teardown] Cleanup Temp Config + + ${cm}= Get Config Manager Instance ${TEMP_PATH} + + ${defaults_update}= Create Dictionary llm=custom-llm embedding=custom-embed + Update Defaults In Config Manager ${cm} ${defaults_update} + + ${defaults}= Call Method ${cm} get_config_defaults + Should Be Equal ${defaults["llm"]} custom-llm + Should Be Equal ${defaults["embedding"]} custom-embed + +Existing Models Preserved After Adding Custom + [Documentation] Adding a custom model should not remove existing models. + [Setup] Setup Temp Config + [Teardown] Cleanup Temp Config + + ${cm}= Get Config Manager Instance ${TEMP_PATH} + ${config_before}= Call Method ${cm} get_full_config + ${models_before}= Get From Dictionary ${config_before} models + ${original_count}= Get Length ${models_before} + + ${new_model}= Create Dictionary + ... name=custom-llm + ... model_type=llm + ... model_name=test-model + ... model_url=https://example.com/v1 + + Add Model To Config Manager ${cm} ${new_model} + + ${config_after}= Call Method ${cm} get_full_config + ${models_after}= Get From Dictionary ${config_after} models + ${new_count}= Get Length ${models_after} + ${expected_count}= Evaluate ${original_count} + 1 + + Should Be Equal As Integers ${new_count} ${expected_count} + + ${model_names}= Create List + FOR ${m} IN @{models_after} + Append To List ${model_names} ${m["name"]} + END + + List Should Contain Value ${model_names} openai-llm + List Should Contain Value ${model_names} local-embed + List Should Contain Value ${model_names} custom-llm diff --git a/tests/configuration/test_transcription_url.robot b/tests/configuration/test_transcription_url.robot new file mode 100644 index 00000000..618771df --- /dev/null +++ b/tests/configuration/test_transcription_url.robot @@ -0,0 +1,126 @@ +*** Settings *** +Documentation Tests for Transcription Service URL Configuration +Library Collections +Library ../libs/ConfigTestHelper.py + +*** Test Cases *** +Vibevoice Url Without Http Prefix + [Documentation] Test that VIBEVOICE_ASR_URL without http:// prefix works correctly. + ${config_template}= Create Dictionary model_url=http://\${oc.env:VIBEVOICE_ASR_URL,host.docker.internal:8767} + ${env_vars}= Create Dictionary VIBEVOICE_ASR_URL=host.docker.internal:8767 + + ${resolved}= Resolve Omega Config ${config_template} ${env_vars} + Should Be Equal ${resolved["model_url"]} http://host.docker.internal:8767 + Should Not Contain ${resolved["model_url"]} http://http:// + +Vibevoice Url With Http Prefix Causes Double Prefix + [Documentation] Test that VIBEVOICE_ASR_URL WITH http:// causes double prefix (bug scenario). + ${config_template}= Create Dictionary model_url=http://\${oc.env:VIBEVOICE_ASR_URL,host.docker.internal:8767} + ${env_vars}= Create Dictionary VIBEVOICE_ASR_URL=http://host.docker.internal:8767 + + ${resolved}= Resolve Omega Config ${config_template} ${env_vars} + Should Be Equal ${resolved["model_url"]} http://http://host.docker.internal:8767 + Should Contain ${resolved["model_url"]} http://http:// + +Vibevoice Url Default Fallback + [Documentation] Test that default fallback works when VIBEVOICE_ASR_URL is not set. + ${config_template}= Create Dictionary model_url=http://\${oc.env:VIBEVOICE_ASR_URL,host.docker.internal:8767} + ${env_vars}= Create Dictionary + + ${resolved}= Resolve Omega Config ${config_template} ${env_vars} + Should Be Equal ${resolved["model_url"]} http://host.docker.internal:8767 + +Parakeet Url Configuration + [Documentation] Test that PARAKEET_ASR_URL follows same pattern. + ${config_template}= Create Dictionary model_url=http://\${oc.env:PARAKEET_ASR_URL,172.17.0.1:8767} + ${env_vars}= Create Dictionary PARAKEET_ASR_URL=host.docker.internal:8767 + + ${resolved}= Resolve Omega Config ${config_template} ${env_vars} + Should Be Equal ${resolved["model_url"]} http://host.docker.internal:8767 + Should Not Contain ${resolved["model_url"]} http://http:// + +Url Parsing Removes Double Slashes + [Documentation] Test that URL with double http:// causes connection failures (simulated by parsing check). + + # Valid URL + ${valid_url}= Set Variable http://host.docker.internal:8767/transcribe + ${parsed_valid}= Check Url Parsing ${valid_url} + Should Be Equal ${parsed_valid["scheme"]} http + Should Be Equal ${parsed_valid["netloc"]} host.docker.internal:8767 + + # Invalid URL + ${invalid_url}= Set Variable http://http://host.docker.internal:8767/transcribe + ${parsed_invalid}= Check Url Parsing ${invalid_url} + Should Be Equal ${parsed_invalid["scheme"]} http + # In python urlparse, 'http:' becomes the netloc for 'http://http://...' + Should Be Equal ${parsed_invalid["netloc"]} http: + Should Not Be Equal ${parsed_invalid["netloc"]} host.docker.internal:8767 + +Diarization Source Defaults To Provider + [Documentation] Test that diarization_source defaults to provider (trust provider diarization, fall back to pyannote). + ${diarization}= Create Dictionary + ${backend}= Create Dictionary diarization=${diarization} + ${config_template}= Create Dictionary backend=${backend} + ${env_vars}= Create Dictionary + + ${resolved}= Resolve Omega Config ${config_template} ${env_vars} + ${val}= Evaluate $resolved.get('backend', {}).get('diarization', {}).get('diarization_source', 'provider') + Should Be Equal ${val} provider + +Diarization Source Explicit Pyannote + [Documentation] Test that diarization_source can be set to pyannote (always re-diarize). + ${diarization}= Create Dictionary diarization_source=pyannote + ${backend}= Create Dictionary diarization=${diarization} + ${config_template}= Create Dictionary backend=${backend} + ${env_vars}= Create Dictionary + + ${resolved}= Resolve Omega Config ${config_template} ${env_vars} + ${val}= Evaluate $resolved['backend']['diarization']['diarization_source'] + Should Be Equal ${val} pyannote + +Vibevoice Provider Diarization Is Trusted + [Documentation] Test that a provider with segments + diarization capabilities qualifies for provider-sourced diarization. + # Logic simulation + ${vibevoice_capabilities}= Create List segments diarization + ${has_diarization}= Evaluate "diarization" in $vibevoice_capabilities + ${has_segments}= Evaluate "segments" in $vibevoice_capabilities + ${provider_diarized}= Evaluate $has_diarization and $has_segments + Should Be Equal ${provider_diarized} ${TRUE} + +Model Registry Url Resolution With Env Var + [Documentation] Test that model URLs resolve correctly from environment. + ${model_def}= Create Dictionary + ... name=stt-vibevoice + ... model_type=stt + ... model_provider=vibevoice + ... model_url=http://\${oc.env:VIBEVOICE_ASR_URL,host.docker.internal:8767} + + ${models}= Create List ${model_def} + ${defaults}= Create Dictionary stt=stt-vibevoice + ${config_template}= Create Dictionary defaults=${defaults} models=${models} + + ${env_vars}= Create Dictionary VIBEVOICE_ASR_URL=host.docker.internal:8767 + + ${resolved}= Resolve Omega Config ${config_template} ${env_vars} + ${resolved_models}= Get From Dictionary ${resolved} models + Should Be Equal ${resolved_models[0]["model_url"]} http://host.docker.internal:8767 + +Multiple Asr Providers Url Resolution + [Documentation] Test that multiple ASR providers can use different URL patterns. + ${m1}= Create Dictionary name=stt-vibevoice model_url=http://\${oc.env:VIBEVOICE_ASR_URL,host.docker.internal:8767} + ${m2}= Create Dictionary name=stt-parakeet model_url=http://\${oc.env:PARAKEET_ASR_URL,172.17.0.1:8767} + ${m3}= Create Dictionary name=stt-deepgram model_url=https://api.deepgram.com/v1 + + ${models}= Create List ${m1} ${m2} ${m3} + ${config_template}= Create Dictionary models=${models} + + ${env_vars}= Create Dictionary + ... VIBEVOICE_ASR_URL=host.docker.internal:8767 + ... PARAKEET_ASR_URL=localhost:8080 + + ${resolved}= Resolve Omega Config ${config_template} ${env_vars} + ${resolved_models}= Get From Dictionary ${resolved} models + + Should Be Equal ${resolved_models[0]["model_url"]} http://host.docker.internal:8767 + Should Be Equal ${resolved_models[1]["model_url"]} http://localhost:8080 + Should Be Equal ${resolved_models[2]["model_url"]} https://api.deepgram.com/v1 diff --git a/tests/endpoints/annotation_suggestions_tests.robot b/tests/endpoints/annotation_suggestions_tests.robot new file mode 100644 index 00000000..4a600b7d --- /dev/null +++ b/tests/endpoints/annotation_suggestions_tests.robot @@ -0,0 +1,68 @@ +*** Settings *** +Documentation Annotation Suggestions Endpoint Tests +... +... Tests for the GET /annotations/suggestions endpoint +... used by the User Loop swipe review UI. +Library RequestsLibrary +Library Collections +Resource ../setup/setup_keywords.robot +Resource ../setup/teardown_keywords.robot +Resource ../resources/session_keywords.robot +Resource ../resources/user_keywords.robot +Suite Setup Suite Setup +Suite Teardown Suite Teardown +Test Setup Test Cleanup + +*** Test Cases *** + +Get Suggestions Returns Empty List When No Suggestions Exist + [Documentation] Verify suggestions endpoint returns empty list for a fresh user + [Tags] infra + + ${session}= Get Admin API Session + ${response}= GET On Session ${session} /api/annotations/suggestions + Should Be Equal As Integers ${response.status_code} 200 + + ${suggestions}= Set Variable ${response.json()} + Should Be Equal As Integers ${suggestions.__len__()} 0 + +Get Suggestions Requires Authentication + [Documentation] Verify suggestions endpoint returns 401 without auth token + [Tags] infra + + Get Anonymous Session anon_session + ${response}= GET On Session anon_session /api/annotations/suggestions expected_status=401 + Should Be Equal As Integers ${response.status_code} 401 + +Get Suggestions Respects Limit Parameter + [Documentation] Verify suggestions endpoint accepts limit query parameter + [Tags] infra + + ${session}= Get Admin API Session + &{params}= Create Dictionary limit=5 + ${response}= GET On Session ${session} /api/annotations/suggestions params=${params} + Should Be Equal As Integers ${response.status_code} 200 + + ${suggestions}= Set Variable ${response.json()} + ${count}= Get Length ${suggestions} + Should Be True ${count} <= 5 Suggestions count should respect limit parameter + +Non Admin User Can Access Own Suggestions + [Documentation] Verify non-admin users can access the suggestions endpoint + [Tags] infra permissions + + # Create a regular test user (random email so leftover users from an + # aborted run can't collide with a 409) + ${session}= Get Admin API Session + ${user}= Create Test User ${session} + + # Login as the test user + Create API Session user_session ${user}[email] ${TEST_USER_PASSWORD} + + ${response}= GET On Session user_session /api/annotations/suggestions + Should Be Equal As Integers ${response.status_code} 200 + + ${suggestions}= Set Variable ${response.json()} + Should Be Equal As Integers ${suggestions.__len__()} 0 + + [Teardown] Run Keyword And Ignore Error Delete User ${session} ${user}[id] diff --git a/tests/endpoints/auth_tests.robot b/tests/endpoints/auth_tests.robot index ac5d829f..ed74b5d8 100644 --- a/tests/endpoints/auth_tests.robot +++ b/tests/endpoints/auth_tests.robot @@ -136,4 +136,3 @@ Delete User Test ${response}= POST On Session api /auth/jwt/login data=${auth_data} headers=${headers} expected_status=400 Should Be Equal As Integers ${response.status_code} 400 - diff --git a/tests/endpoints/chat_tests.robot b/tests/endpoints/chat_tests.robot index e2846904..06f75021 100644 --- a/tests/endpoints/chat_tests.robot +++ b/tests/endpoints/chat_tests.robot @@ -40,7 +40,7 @@ Create Chat Session With Custom Title Test ${session}= Set Variable ${response.json()} Should Be Equal ${session}[title] ${custom_title} - + Get Chat Sessions Test [Documentation] Test getting all chat sessions for user [Tags] chat @@ -239,4 +239,3 @@ User Isolation Test Should Be Equal As Integers ${count} 0 Delete User api ${test_user}[id] - diff --git a/tests/endpoints/client_queue_tests.robot b/tests/endpoints/client_queue_tests.robot index b161b7fa..03d21c4a 100644 --- a/tests/endpoints/client_queue_tests.robot +++ b/tests/endpoints/client_queue_tests.robot @@ -174,7 +174,7 @@ Unauthorized Queue Access Test [Documentation] Test that queue endpoints require authentication [Tags] queue security negative ${session}= Get Anonymous Session session - + # Try to access queue jobs without token ${response}= GET On Session ${session} /api/queue/jobs expected_status=401 Should Be Equal As Integers ${response.status_code} 401 @@ -197,4 +197,3 @@ Client Manager Integration Test ${clients}= Set Variable ${response.json()} # Verify structure - should be a valid JSON response Should Be True isinstance($clients, (dict, list)) - diff --git a/tests/endpoints/conversation_tests.robot b/tests/endpoints/conversation_tests.robot index 565d5416..f7b72050 100644 --- a/tests/endpoints/conversation_tests.robot +++ b/tests/endpoints/conversation_tests.robot @@ -21,7 +21,7 @@ Get User Conversations Test ${conversations_data}= Get User Conversations # Verify conversation structure if any exist - + IF isinstance($conversations_data, dict) and len($conversations_data) > 0 ${client_ids}= Get Dictionary Keys ${conversations_data} FOR ${client_id} IN @{client_ids} @@ -66,7 +66,7 @@ Reprocess test and get Conversation Versions Test ${expected_count}= Evaluate ${start_num_versions} + 1 Should Be Equal As Integers ${conversation}[transcript_version_count] ${expected_count} - Should be equal as strings ${conversation}[active_transcript_version] ${updated_versions}[-1][version_id] + Should be equal as strings ${conversation}[active_transcript_version] ${updated_versions}[-1][version_id] Unauthorized Conversation Access Test @@ -97,9 +97,10 @@ Reprocess Memory Test ${conversation_id}= Set Variable ${test_conversation}[conversation_id] - # Get initial memory version count - ${initial_memory_count}= Set Variable ${test_conversation}[memory_version_count] - Log Initial memory version count: ${initial_memory_count} + # Get initial memory audit ledger size + ${initial_audit}= Get Conversation Memory Audit ${conversation_id} + ${initial_count}= Get Length ${initial_audit} + Log Initial memory audit entries: ${initial_count} # Trigger memory reprocessing ${response}= Reprocess Memory ${conversation_id} @@ -113,21 +114,18 @@ Reprocess Memory Test ${job_id}= Set Variable ${response}[job_id] Wait For Job Status ${job_id} finished timeout=60s interval=5s - # Verify new memory version was created - ${updated_conversation}= Get Conversation By ID ${conversation_id} - ${new_memory_count}= Set Variable ${updated_conversation}[memory_version_count] - Log New memory version count: ${new_memory_count} + # Verify the reprocess recorded a new vault change in the audit ledger + ${updated_audit}= Get Conversation Memory Audit ${conversation_id} + ${new_count}= Get Length ${updated_audit} + Log New memory audit entries: ${new_count} - Should Be True ${new_memory_count} > ${initial_memory_count} Expected memory version count to increase + Should Be True ${new_count} > ${initial_count} Expected memory audit ledger to grow after reprocessing - ${memory_versions}= Get conversation memory versions ${conversation_id} - Length Should Be ${memory_versions} ${new_memory_count} - Close Conversation Test [Documentation] Test closing current conversation for a client [Tags] conversation - + Skip msg=Close conversation needs to be evaluated as to it's purpose from the client side Get Anonymous Session anon_session @@ -181,16 +179,9 @@ Transcript Version activate Test # Test activating a different version (activate version index 1) ${target_version}= Set Variable ${versions}[1][version_id] ${response}= Activate Transcript Version ${conversation_id} ${target_version} - Should Be Equal As Strings ${response}[active_transcript_version] ${target_version} + Should Be Equal As Strings ${response}[active_transcript_version] ${target_version} - # ${active_memory}= Get memory versions - # ... ${test}[active_memory_version] - # IF '${active_memory}' != '${None}' and '${active_memory}' != 'null' - # ${response}= Activate Memory Version ${conversation_id} ${active_memory} - # Should Be Equal As Integers ${response.status_code} 200 - # END - Get conversation permission Test [Documentation] Test that users can only access their own conversations [Tags] conversation permissions @@ -264,4 +255,3 @@ Filter Starred Conversations IF ${conv_check}[starred] Star Conversation ${conversation_id} END - diff --git a/tests/endpoints/data_audit_tests.robot b/tests/endpoints/data_audit_tests.robot new file mode 100644 index 00000000..23d49668 --- /dev/null +++ b/tests/endpoints/data_audit_tests.robot @@ -0,0 +1,129 @@ +*** Settings *** +Documentation Data Audit API Tests +... +... Tests for the data-audit endpoints: +... - Filtered conversation listing with VAD speech metrics +... - Silence-gap detection (split candidates) +... - Conversation split (chunk reassignment + transcript slicing) +... - Conversation merge (adjacent conversations) + +Library RequestsLibrary +Library Collections +Resource ../setup/setup_keywords.robot +Resource ../setup/teardown_keywords.robot +Resource ../resources/conversation_keywords.robot +Resource ../resources/queue_keywords.robot + +Suite Setup Suite Setup +Suite Teardown Suite Teardown + +Test Tags conversation + + +*** Test Cases *** +Data Audit List Returns Speech Metrics + [Documentation] The audit listing responds with VAD-based fields and paging metadata + + ${response}= GET On Session api /api/data-audit/conversations expected_status=200 + ${body}= Set Variable ${response.json()} + Dictionary Should Contain Key ${body} conversations + Dictionary Should Contain Key ${body} total + Dictionary Should Contain Key ${body} speech_threshold + Dictionary Should Contain Key ${body} scan_capped + +Silence Gaps On Unknown Conversation Returns Not Found + [Documentation] Gap detection on a nonexistent conversation is a 404 + + GET On Session api /api/data-audit/conversations/nonexistent-conversation/silence-gaps + ... expected_status=404 + +Split On Unknown Conversation Returns Not Found + [Documentation] Splitting a nonexistent conversation is a 404 + + ${payload}= Create Dictionary split_points=${{[30.0]}} + POST On Session api /api/data-audit/conversations/nonexistent-conversation/split + ... json=${payload} expected_status=404 + +Merge Requires At Least Two Conversations + [Documentation] Request validation rejects a single-conversation merge + + ${payload}= Create Dictionary conversation_ids=${{['only-one-id']}} + POST On Session api /api/data-audit/merge json=${payload} expected_status=422 + +Full Split And Merge Round Trip + [Documentation] Upload audio → VAD analyze → split at the midpoint → verify two + ... children with reassigned chunks and re-timed transcripts → merge + ... the children back and verify totals and source soft-deletion. + [Tags] conversation requires-api-keys + [Timeout] 600s + + # Arrange — create a conversation with audio chunks and a transcript + ${conversation}= Create Test Conversation device_name=audit-split + ${conversation_id}= Set Variable ${conversation}[conversation_id] + + # Act — force VAD analysis and wait for the job to finish + ${analyze_payload}= Create Dictionary + ... conversation_ids=${{['${conversation_id}']}} force=${True} + ${analyze_response}= POST On Session api /api/data-audit/analyze + ... json=${analyze_payload} expected_status=200 + ${job_id}= Set Variable ${analyze_response.json()}[job_id] + Wait For Job Status ${job_id} finished timeout=180s interval=3s + + # Assert — gap endpoint now reports the conversation as analyzed + ${gaps_response}= GET On Session api + ... /api/data-audit/conversations/${conversation_id}/silence-gaps + ... expected_status=200 + Should Be True ${gaps_response.json()}[analyzed] Conversation should be analyzed after the VAD job + ${duration}= Set Variable ${gaps_response.json()}[duration_seconds] + Should Be True ${duration} > 20 Test asset should be longer than 20s, got ${duration} + + # Act — split at the midpoint (split points are caller-supplied, so no + # real 15-minute silence gap is needed for the mechanics to be exercised) + ${midpoint}= Evaluate round(${duration} / 2, 1) + ${split_payload}= Create Dictionary split_points=${{[${midpoint}]}} + ${split_response}= POST On Session api + ... /api/data-audit/conversations/${conversation_id}/split + ... json=${split_payload} expected_status=200 + ${children}= Set Variable ${split_response.json()}[children] + ${child_count}= Get Length ${children} + Should Be Equal As Integers ${child_count} 2 Split at one point should produce two children + + # Assert — children have chunks and re-timed transcripts starting near zero + ${total_child_chunks}= Set Variable ${0} + FOR ${child} IN @{children} + Should Be True ${child}[chunk_count] > 0 Child should own at least one audio chunk + ${total_child_chunks}= Evaluate ${total_child_chunks} + ${child}[chunk_count] + ${child_doc}= Get Conversation By ID ${child}[conversation_id] + Should Be Equal ${child_doc}[conversation_id] ${child}[conversation_id] + END + + # Assert — the parent no longer appears in the active conversation list + ${remaining}= Get Conversations By Client ID ${conversation}[client_id] + FOR ${conv} IN @{remaining} + Should Not Be Equal ${conv}[conversation_id] ${conversation_id} + ... Soft-deleted parent should not be listed + END + + # Assert — a re-split of the already-split parent is rejected + POST On Session api /api/data-audit/conversations/${conversation_id}/split + ... json=${split_payload} expected_status=409 + + # Act — merge the two children back together + ${child_ids}= Evaluate [c['conversation_id'] for c in ${children}] + ${merge_payload}= Create Dictionary conversation_ids=${child_ids} + ${merge_response}= POST On Session api /api/data-audit/merge + ... json=${merge_payload} expected_status=200 + ${merged}= Set Variable ${merge_response.json()} + + # Assert — merged conversation owns all the chunks and the sources are gone + Should Be Equal As Integers ${merged}[chunk_count] ${total_child_chunks} + ... Merged conversation should own every child chunk + ${merged_doc}= Get Conversation By ID ${merged}[merged_conversation_id] + Should Be Equal ${merged_doc}[conversation_id] ${merged}[merged_conversation_id] + ${after_merge}= Get Conversations By Client ID ${conversation}[client_id] + FOR ${conv} IN @{after_merge} + Should Not Contain ${child_ids} ${conv}[conversation_id] + ... Soft-deleted merge sources should not be listed + END + + Log To Console ✅ Split ${conversation_id} into 2 parts and merged them back diff --git a/tests/endpoints/health_tests.robot b/tests/endpoints/health_tests.robot index dc734496..06a6cf6a 100644 --- a/tests/endpoints/health_tests.robot +++ b/tests/endpoints/health_tests.robot @@ -37,20 +37,19 @@ Health Check Test Dictionary Should Contain Key ${health} services Dictionary Should Contain Key ${health} overall_healthy Dictionary Should Contain Key ${health} critical_services_healthy - + ${services}= Set Variable ${health}[services] Log To Console \n - Log To Console Mongodb: ${services}[mongodb][status] - Log To Console AudioAI: ${services}[audioai][status] + Log To Console Mongodb: ${services}[mongodb][status] + Log To Console LLM: ${services}[llm][status] Log To Console Memory Service: ${services}[memory_service][status] Log To Console Speech to Text: ${services}[speech_to_text][status] Log To Console Speaker recognition: ${services}[speaker_recognition][status] # Verify status is one of expected values Should Be True '${health}[status]' in ['healthy', 'degraded', 'critical'] - + ${config}= Set Variable ${health}[config] Dictionary Should Contain Key ${config} mongodb_uri - Dictionary Should Contain Key ${config} qdrant_url Dictionary Should Contain Key ${config} transcription_service Dictionary Should Contain Key ${config} asr_uri Dictionary Should Contain Key ${config} provider_type @@ -63,7 +62,6 @@ Health Check Test # Verify config values are not empty Should Not Be Empty ${config}[mongodb_uri] - Should Not Be Empty ${config}[qdrant_url] Should Not Be Empty ${config}[transcription_service] Should Not Be Empty ${config}[asr_uri] Should Not Be Empty ${config}[provider_type] @@ -168,7 +166,7 @@ Health Check Service Details Test ${services}= Set Variable ${health}[services] # Check for expected services - ${expected_services}= Create List mongodb redis audioai memory_service speech_to_text + ${expected_services}= Create List mongodb redis llm memory_service speech_to_text FOR ${service} IN @{expected_services} IF '${service}' in $services @@ -212,4 +210,3 @@ Unauthorized Health Access Test # Admin-only endpoints should require authentication ${response}= GET On Session session /api/metrics expected_status=401 Should Be Equal As Integers ${response.status_code} 401 - diff --git a/tests/endpoints/memory_tests.robot b/tests/endpoints/memory_tests.robot index b12a4ff6..d109dafe 100644 --- a/tests/endpoints/memory_tests.robot +++ b/tests/endpoints/memory_tests.robot @@ -37,11 +37,8 @@ Get User Memories Test Dictionary Should Contain Key ${memory} memory Dictionary Should Contain Key ${memory} created_at Dictionary Should Contain Key ${memory} updated_at - Dictionary Should Contain Key ${metadata} source - Dictionary Should Contain Key ${metadata} client_id - Dictionary Should Contain Key ${metadata} source_id Dictionary Should Contain Key ${metadata} user_id - Dictionary Should Contain Key ${metadata} user_email + Dictionary Should Contain Key ${metadata} conversation_id # Verify timestamps are valid (not "Invalid Date", not empty) Should Not Be Equal ${memory}[created_at] ${EMPTY} created_at should not be empty @@ -49,11 +46,9 @@ Get User Memories Test Should Not Be Equal ${memory}[created_at] Invalid Date created_at should not be "Invalid Date" Should Not Be Equal ${memory}[updated_at] Invalid Date updated_at should not be "Invalid Date" - # Verify timestamps are numeric strings (Unix timestamps) - ${created_timestamp}= Convert To Integer ${memory}[created_at] - ${updated_timestamp}= Convert To Integer ${memory}[updated_at] - Should Be True ${created_timestamp} > 0 created_at should be a positive timestamp - Should Be True ${updated_timestamp} > 0 updated_at should be a positive timestamp + # Verify timestamps are present and non-empty + Should Not Be Equal ${memory}[created_at] ${NONE} created_at should not be None + Should Not Be Equal ${memory}[updated_at] ${NONE} updated_at should not be None # Check if memory contains "trumpet flower" ${memory_text}= Convert To String ${memory}[memory] @@ -153,4 +148,3 @@ Unauthorized Memory Access Test &{params}= Create Dictionary query=test ${response}= GET On Session session /api/memories/search params=${params} expected_status=401 Should Be Equal As Integers ${response.status_code} 401 - diff --git a/tests/endpoints/rq_queue_tests.robot b/tests/endpoints/rq_queue_tests.robot index cbd58d96..e0573dbe 100644 --- a/tests/endpoints/rq_queue_tests.robot +++ b/tests/endpoints/rq_queue_tests.robot @@ -194,4 +194,4 @@ Test Queue API Authentication ${response}= GET On Session anon_session /api/queue/stats expected_status=401 Should Be Equal As Integers ${response.status_code} 401 - Log Queue API authentication properly enforced \ No newline at end of file + Log Queue API authentication properly enforced diff --git a/tests/endpoints/system_admin_tests.robot b/tests/endpoints/system_admin_tests.robot index eb33c039..b86be360 100644 --- a/tests/endpoints/system_admin_tests.robot +++ b/tests/endpoints/system_admin_tests.robot @@ -149,82 +149,6 @@ Delete All User Memories Test Dictionary Should Contain Key ${result} message -Get Chat Configuration Test - [Documentation] Test getting chat system prompt (admin only) - [Tags] infra permissions - - # First ensure default prompt is set (cleanup from previous test runs) - ${default_prompt}= Set Variable You are a helpful AI assistant with access to the user's personal memories and conversation history. - &{headers}= Create Dictionary Content-Type=text/plain - ${response}= POST On Session api /api/admin/chat/config - ... data=${default_prompt} - ... headers=${headers} - Should Be Equal As Integers ${response.status_code} 200 - - # Now test getting the default prompt - ${response}= GET On Session api /api/admin/chat/config - Should Be Equal As Integers ${response.status_code} 200 - - # Response should be plain text - ${prompt}= Set Variable ${response.text} - Should Not Be Empty ${prompt} - Should Not Contain ${prompt} system_prompt: msg=Should not contain YAML key - Should Contain ${prompt} helpful AI assistant msg=Should contain default prompt content - -Validate Chat Configuration Test - [Documentation] Test chat configuration validation - [Tags] infra permissions - - # Valid prompt should pass - ${valid_prompt}= Set Variable You are a friendly AI assistant that helps users with their daily tasks. - &{headers}= Create Dictionary Content-Type=text/plain - ${response}= POST On Session api /api/admin/chat/config/validate - ... data=${valid_prompt} - ... headers=${headers} - Should Be Equal As Integers ${response.status_code} 200 - ${result}= Set Variable ${response.json()} - Should Be True ${result}[valid] == $True - - # Too short should fail - ${short_prompt}= Set Variable Hi - ${response}= POST On Session api /api/admin/chat/config/validate - ... data=${short_prompt} - ... headers=${headers} - Should Be Equal As Integers ${response.status_code} 200 - ${result}= Set Variable ${response.json()} - Should Be True ${result}[valid] == $False - Should Contain ${result}[error] too short msg=Error should mention prompt is too short - -Save And Retrieve Chat Configuration Test - [Documentation] Test saving and retrieving chat configuration - [Tags] infra permissions - - # Define known default prompt for restoration (from system_controller.py and chat_service.py) - ${default_prompt}= Set Variable You are a helpful AI assistant with access to the user's personal memories and conversation history. - - # Save custom prompt - ${custom_prompt}= Set Variable You are a specialized AI assistant for technical support and troubleshooting. - &{headers}= Create Dictionary Content-Type=text/plain - ${response}= POST On Session api /api/admin/chat/config - ... data=${custom_prompt} - ... headers=${headers} - Should Be Equal As Integers ${response.status_code} 200 - ${result}= Set Variable ${response.json()} - Should Be True ${result}[success] == $True - - # Retrieve and verify - ${response}= GET On Session api /api/admin/chat/config - Should Be Equal As Integers ${response.status_code} 200 - ${retrieved}= Set Variable ${response.text} - Should Be Equal ${retrieved} ${custom_prompt} msg=Retrieved prompt should match saved prompt - - # Restore default prompt to avoid test interference - ${response}= POST On Session api /api/admin/chat/config - ... data=${default_prompt} - ... headers=${headers} - Should Be Equal As Integers ${response.status_code} 200 - - Non-Admin Cannot Access Admin Endpoints Test [Documentation] Test that non-admin users cannot access admin endpoints [Tags] infra permissions @@ -239,7 +163,6 @@ Non-Admin Cannot Access Admin Endpoints Test ... /api/speaker-service-status ... /api/admin/memory/config/raw ... /api/admin/memory/config/reload - ... /api/admin/chat/config ... /api/process-audio-files/jobs FOR ${endpoint} IN @{endpoints} @@ -329,4 +252,3 @@ Speaker Configuration Workflow Test ${speakers_list}= Set Variable ${updated_config}[primary_speakers] ${length}= Get Length ${speakers_list} Should Be Equal As Integers ${length} 0 - diff --git a/tests/infrastructure/infra_tests.robot b/tests/infrastructure/infra_tests.robot index 53d90f3a..262d7ed4 100644 --- a/tests/infrastructure/infra_tests.robot +++ b/tests/infrastructure/infra_tests.robot @@ -256,10 +256,10 @@ Worker Count Validation Test Log To Console Idle workers: ${idle_workers} # Verify exact worker count - # Expected: 9 RQ workers (6 general workers + 3 audio persistence workers) + # Expected: 10 RQ workers (6 general + 1 memory + 3 audio persistence) # Note: Audio stream workers (Deepgram/Parakeet) are NOT RQ workers - they don't register # We wait up to 20s for registration, so all workers should be present - Should Be Equal As Integers ${worker_count} 9 msg=Expected exactly 9 RQ workers (6 general + 3 audio persistence) + Should Be Equal As Integers ${worker_count} 10 msg=Expected exactly 10 RQ workers (6 general + 1 memory + 3 audio persistence) # Verify active + idle = total ${sum}= Evaluate ${active_workers} + ${idle_workers} @@ -300,70 +300,51 @@ Redis Connection Resilience Test Log To Console \n✅ Redis health check working correctly WebSocket Disconnect Conversation End Reason Test - [Documentation] Test that WebSocket disconnects are tracked with proper end_reason + [Documentation] Test that abrupt WebSocket disconnects produce end_reason='websocket_disconnect'. ... - ... This test simulates a Bluetooth/network dropout scenario: - ... 1. Start streaming audio and create conversation - ... 2. Keep sending audio to prevent inactivity timeout - ... 3. Abruptly close WebSocket (simulating disconnect) - ... 4. Verify job exits gracefully (no 3600s timeout) - ... 5. Verify conversation has end_reason='websocket_disconnect' + ... Simulates a Bluetooth/network dropout: + ... 1. Stream audio in small bursts while waiting for conversation + ... 2. Once conversation is created, abruptly close WebSocket + ... 3. Verify conversation closes with end_reason=websocket_disconnect [Tags] infra audio-streaming + [Timeout] 180s - # Start audio stream and send chunks to trigger conversation + # Arrange ${device_name}= Set Variable disconnect ${client_id}= Get Client ID From Device Name ${device_name} ${stream_id}= Open Audio Stream device_name=${device_name} - # Send audio fast (no realtime pacing) to trigger conversation creation - Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=200 - - # Initialize conversation_id to None (will be set when found) - ${conversation_id}= Set Variable ${None} - - # Keep sending audio in a loop to prevent inactivity timeout while waiting for conversation - # We need to continuously send audio because SPEECH_INACTIVITY_THRESHOLD_SECONDS=2 - FOR ${i} IN RANGE 20 # Send 20 batches while waiting - # Try to get conversation job - ${conv_jobs}= Get Jobs By Type And Client open_conversation ${client_id} - ${has_job}= Evaluate len($conv_jobs) > 0 - - IF ${has_job} - # Conversation job exists, try to get conversation_id - TRY - ${conversation_id}= Get Conversation ID From Job Meta open_conversation ${client_id} - # Got conversation_id! Close websocket immediately to trigger disconnect - Log To Console Conversation created (${conversation_id}), closing websocket NOW - Close Audio Stream ${stream_id} + # Baseline: track existing jobs so we only look at the NEW one + ${baseline_jobs}= Get Jobs By Type And Client open_conversation ${client_id} + ${baseline_count}= Get Length ${baseline_jobs} + + # Send audio in small bursts interleaved with job checks. + # realtime_pacing + small batches lets Deepgram produce streaming results + # while we poll for the conversation job between bursts. + ${conversation_id}= Set Variable ${EMPTY} + FOR ${i} IN RANGE 30 + Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} + ... num_chunks=50 realtime_pacing=True + + ${status}= Run Keyword And Return Status + ... Wait For New Job To Appear open_conversation ${client_id} ${baseline_count} + IF ${status} + ${jobs}= Get Jobs By Type And Client open_conversation ${client_id} + ${current_count}= Get Length ${jobs} + IF ${current_count} > ${baseline_count} + ${conversation_id}= Evaluate $jobs[0]['meta'].get('conversation_id', '') BREAK - EXCEPT - # conversation_id not set yet, keep sending audio - Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=50 - Sleep 1s END - ELSE - # No conversation job yet, keep sending audio - Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=50 - Sleep 1s END END + Should Not Be Empty ${conversation_id} msg=Conversation never created within timeout - # Verify we got the conversation_id before loop ended - Should Not Be Equal ${conversation_id} ${None} Failed to get conversation_id within timeout - - # Wait for job to complete (should be fast, not 3600s timeout) - ${conv_jobs}= Get Jobs By Type And Client open_conversation ${device_name} - ${conv_job}= Get Most Recent Job ${conv_jobs} - Wait For Job Status ${conv_job}[job_id] finished timeout=60s interval=2s + # Act: abrupt disconnect (no audio-stop event → triggers websocket_disconnect) + Log To Console Conversation ${conversation_id} active, disconnecting NOW + Close Audio Stream Without Stop Event ${stream_id} - # Wait for end_reason to be saved to database (retry with timeout) - ${conversation}= Wait Until Keyword Succeeds 10s 0.5s - ... Check Conversation Has End Reason ${conversation_id} - - # Verify conversation was saved with correct end_reason - ${end_reason}= Set Variable ${conversation}[end_reason] - Should Be Equal As Strings ${end_reason} websocket_disconnect - Should Not Be Equal ${conversation}[completed_at] ${None} + # Assert: conversation closes with correct end_reason + Wait Until Keyword Succeeds 30s 2s + ... Conversation Should Have End Reason ${conversation_id} websocket_disconnect [Teardown] Run Keyword And Ignore Error Close Audio Stream ${stream_id} - diff --git a/tests/integration/always_persist_audio_tests.robot b/tests/integration/always_persist_audio_tests.robot index d27dee81..2576a465 100644 --- a/tests/integration/always_persist_audio_tests.robot +++ b/tests/integration/always_persist_audio_tests.robot @@ -49,7 +49,7 @@ Test Cleanup Placeholder Conversation Created Immediately With Always Persist [Documentation] Verify that when always_persist=true, a conversation is created ... immediately (before speech detection) with placeholder title and - ... processing_status="pending_transcription". + ... processing_status="active". [Tags] conversation audio-streaming ${device_name}= Set Variable test-placeholder @@ -79,8 +79,8 @@ Placeholder Conversation Created Immediately With Always Persist # Verify placeholder title Verify Placeholder Conversation Title ${conversation_id} - # Verify processing_status - Verify Conversation Processing Status ${conversation_id} pending_transcription + # Verify processing_status (3-state machine: still in flight -> active) + Verify Conversation Processing Status ${conversation_id} active # Verify always_persist flag Verify Conversation Always Persist Flag ${conversation_id} @@ -303,7 +303,7 @@ Audio Chunks Persisted Despite Transcription Failure Conversation Updates To Completed When Transcription Succeeds [Documentation] Verify that when transcription succeeds, the placeholder conversation - ... updates from processing_status="pending_transcription" to "completed", + ... updates from processing_status="active" to "completed", ... and the title updates from placeholder to actual summary. [Tags] conversation audio-streaming requires-api-keys @@ -324,8 +324,8 @@ Conversation Updates To Completed When Transcription Succeeds ${conversation}= Set Variable ${convs_after}[0] ${conversation_id}= Set Variable ${conversation}[conversation_id] - # Verify initial placeholder state - Verify Conversation Processing Status ${conversation_id} pending_transcription + # Verify initial placeholder state (3-state machine: still in flight -> active) + Verify Conversation Processing Status ${conversation_id} active Verify Placeholder Conversation Title ${conversation_id} # Send audio chunks with speech (transcription will succeed) diff --git a/tests/integration/audio_streaming_integration_tests.robot b/tests/integration/audio_streaming_integration_tests.robot index f34b7984..3ae29331 100644 --- a/tests/integration/audio_streaming_integration_tests.robot +++ b/tests/integration/audio_streaming_integration_tests.robot @@ -65,29 +65,33 @@ Redis Session Schema Contains All Required Fields Chunk Count Increments In Redis Session - [Documentation] Verify chunk count is tracked in Redis (not ClientState) + [Documentation] Verify chunk count is tracked in Redis (not ClientState). + ... Note: The producer re-chunks client audio into 250ms fixed-size chunks + ... (8000 bytes at 16kHz/16-bit/mono). Client sends 100ms chunks (3200 bytes). + ... So N client chunks produce floor(N * 3200 / 8000) published chunks. [Tags] infra audio-streaming ${device_name}= Set Variable chunk-count-test ${stream_id}= Open Audio Stream device_name=${device_name} ${client_id}= Get Client ID From Device Name ${device_name} - # Send chunks and verify count increases - Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=3 + # Send first batch: 10 client chunks (10 * 3200 = 32000 bytes → 4 published chunks) + Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=10 Sleep 1s # Allow chunk counter to update ${session1}= Get Redis Session Data ${client_id} ${count1}= Convert To Integer ${session1}[chunks_published] + Should Be True ${count1} > 0 First batch should produce at least 1 published chunk - Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=5 + # Send second batch: 10 more client chunks + Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=10 Sleep 1s # Allow chunk counter to update ${session2}= Get Redis Session Data ${client_id} ${count2}= Convert To Integer ${session2}[chunks_published] - # Verify count increased (should be at least 8) - Should Be True ${count2} > ${count1} - Should Be True ${count2} >= 8 + # Verify count increased between batches + Should Be True ${count2} > ${count1} Chunk count should increase after sending more audio (${count1} → ${count2}) - Log ✅ Chunk count tracked in Redis: ${count1} → ${count2} + Log Chunk count tracked in Redis: ${count1} → ${count2} # Close stream after test completes ${total_chunks}= Close Audio Stream ${stream_id} diff --git a/tests/integration/conversation_queue.robot b/tests/integration/conversation_queue.robot index e0a3b283..29a215b9 100644 --- a/tests/integration/conversation_queue.robot +++ b/tests/integration/conversation_queue.robot @@ -27,7 +27,7 @@ Test Upload audio creates transcription job # Verify queue is empty ${initial_job_count}= Get queue length - + # Upload audio file to create conversation and trigger transcription job ${conversation}= Upload Audio File ${TEST_AUDIO_FILE} ${TEST_DEVICE_NAME} @@ -102,11 +102,10 @@ Test Reprocess Conversation Job Queue # Verify conversation was updated with new transcript version ${updated_conversation}= Get Conversation By ID ${conversation_id} - ${transcript_versions}= Get Conversation Versions ${conversation_id} + ${transcript_versions}= Get Conversation Versions ${conversation_id} Length Should Be ${transcript_versions} ${initial_version_count + 1} Expected transcript versions to increase by 1 # Verify transcript versions array exists and has correct count Should Not Be Equal ${updated_conversation}[active_transcript_version] ${active_version} Log Reprocess Job Queue Test Completed Successfully INFO - diff --git a/tests/integration/integration_test.robot b/tests/integration/integration_test.robot index a9b1230f..a43b54e6 100644 --- a/tests/integration/integration_test.robot +++ b/tests/integration/integration_test.robot @@ -28,7 +28,7 @@ Full Pipeline Integration Test Log Starting Full Pipeline Integration Test INFO - + # Phase 4: Audio Processing - Upload and wait for conversation completion Log Starting audio upload and started INFO ${conversation}= Upload Audio File ${TEST_AUDIO_FILE} ${TEST_DEVICE_NAME} @@ -66,12 +66,20 @@ Audio Playback And Segment Timing Test # Refresh conversation data ${conversation}= Get Conversation By ID ${conversation_id} - # Verify original audio is accessible + # Verify default audio format is opus/ogg ${audio_response}= GET On Session api /api/audio/get_audio/${conversation_id} expected_status=200 - Should Be Equal As Strings ${audio_response.headers}[content-type] audio/wav + Should Be Equal As Strings ${audio_response.headers}[content-type] audio/ogg ${original_audio_size}= Get Length ${audio_response.content} Should Be True ${original_audio_size} > 1000 Original audio file too small: ${original_audio_size} bytes - Log Original audio accessible: ${original_audio_size} bytes INFO + Log Original audio (opus) accessible: ${original_audio_size} bytes INFO + + # Verify explicit wav format returns audio/wav + ${wav_params}= Create Dictionary format=wav + ${wav_response}= GET On Session api /api/audio/get_audio/${conversation_id} params=${wav_params} expected_status=200 + Should Be Equal As Strings ${wav_response.headers}[content-type] audio/wav + ${wav_audio_size}= Get Length ${wav_response.content} + Should Be True ${wav_audio_size} > 1000 WAV audio file too small: ${wav_audio_size} bytes + Log WAV audio accessible: ${wav_audio_size} bytes INFO # Verify segments exist and have valid timestamps Dictionary Should Contain Key ${conversation} segments @@ -252,5 +260,3 @@ Verify Chat Integration Should Be True ${response.status_code} in [200, 204] Chat session deletion failed with status ${response.status_code} Log Chat integration verification finished INFO - - diff --git a/tests/integration/mobile_client_tests.robot b/tests/integration/mobile_client_tests.robot index cd3d2997..4155642b 100644 --- a/tests/integration/mobile_client_tests.robot +++ b/tests/integration/mobile_client_tests.robot @@ -3,7 +3,7 @@ Documentation Debug Pipeline Step by Step Resource ../setup/setup_keywords.robot Resource ../setup/teardown_keywords.robot Suite Setup Suite Setup -Suite Teardown Suite Teardown +Suite Teardown Suite Teardown Test Setup Test Cleanup *** Test Cases *** diff --git a/tests/integration/reconnect_resilience_tests.robot b/tests/integration/reconnect_resilience_tests.robot new file mode 100644 index 00000000..6e10b78d --- /dev/null +++ b/tests/integration/reconnect_resilience_tests.robot @@ -0,0 +1,99 @@ +*** Settings *** +Documentation Reconnect resilience and leading-silence trimming (always_persist). +... +... These exercise the audio-capture lifecycle that the unit/DB tests +... cover in isolation: +... +... 1. A mid-session reconnect (abrupt disconnect, then reopen the same +... device) must NOT strand a transcript on a conversation that gets +... deleted for missing audio. Guards the single-flight persistence job +... + the salvage guard. +... +... 2. With always_persist on, a long silence before speech is recorded on +... the placeholder. At finalize the leading silence is split off onto a +... soft-deleted remnant (audio kept in Mongo) so the visible conversation +... begins at the first speech. +... +... Both need real streaming transcription (the no-API mock is batch only), +... so they are tagged requires-api-keys and slow. + +Resource ../resources/websocket_keywords.robot +Resource ../resources/conversation_keywords.robot +Resource ../resources/mongodb_keywords.robot +Resource ../resources/session_keywords.robot +Variables ../setup/test_env.py + +Suite Setup Get Admin API Session +Test Teardown Cleanup All Audio Streams + +*** Variables *** +${SPEECH_AUDIO} ${CURDIR}/../test_assets/DIY_Experts_Glass_Blowing_16khz_mono_1min.wav +${SILENCE_AUDIO} ${CURDIR}/../test_assets/silence_60s_16khz_mono.wav + +*** Test Cases *** + +Reconnect Mid Session Does Not Orphan Transcripts + [Documentation] An abrupt disconnect followed by a reconnect on the same device + ... must not leave a transcript-bearing conversation soft-deleted as + ... audio_chunks_not_ready. Reproduces the reconnect orphaning bug. + [Tags] e2e audio-streaming requires-api-keys slow + [Timeout] 300s + + ${device}= Set Variable test-reconnect + ${client_id}= Get Client ID From Device Name ${device} + + # Arrange + Act — first leg: speak, so the placeholder gains a real transcript. + ${stream}= Open Audio Stream With Always Persist device_name=${device} + Send Audio Chunks To Stream ${stream} ${SPEECH_AUDIO} num_chunks=60 + Sleep 8s # let streaming transcription land a transcript on the conversation + + # Abrupt disconnect (network drop), then reconnect on the SAME device. + Close Audio Stream Without Stop Event ${stream} + Sleep 3s + ${stream2}= Open Audio Stream With Always Persist device_name=${device} + Send Audio Chunks To Stream ${stream2} ${SPEECH_AUDIO} num_chunks=60 + Sleep 5s + Close Audio Stream ${stream2} + + # Let post-conversation processing (and any audio_chunks_not_ready deletions) settle. + Sleep 20s + + # Assert — no transcript was stranded on a deleted conversation. + ${orphans}= Get Orphaned Transcript Count ${client_id} + Should Be Equal As Integers ${orphans} 0 + ... Reconnect stranded ${orphans} transcript(s) on audio_chunks_not_ready conversations + +Leading Silence Is Trimmed Off The Conversation + [Documentation] With always_persist on, a long silence before speech is split off + ... onto a soft-deleted "leading_silence" remnant so the visible + ... conversation begins at the speech (silence audio stays in Mongo). + [Tags] e2e audio-streaming requires-api-keys slow + [Timeout] 300s + + ${device}= Set Variable test-silence + ${client_id}= Get Client ID From Device Name ${device} + + # Arrange + Act — 60s of silence, then speech, in one continuous session. + ${stream}= Open Audio Stream With Always Persist device_name=${device} + Send Audio Chunks To Stream ${stream} ${SILENCE_AUDIO} + Send Audio Chunks To Stream ${stream} ${SPEECH_AUDIO} num_chunks=60 + Sleep 8s + Close Audio Stream ${stream} + Sleep 20s + + # Assert — a soft-deleted leading-silence remnant exists, and the visible + # conversation does NOT carry the full ~60s+ of leading silence. + ${conversations}= Get Client Conversations Including Deleted ${client_id} + ${silence_remnants}= Create List + ${visible_durations}= Create List + FOR ${conv} IN @{conversations} + IF '${conv}[deletion_reason]' == 'leading_silence' + Append To List ${silence_remnants} ${conv} + END + IF not ${conv}[deleted] + Append To List ${visible_durations} ${conv}[audio_total_duration] + END + END + ${remnant_count}= Get Length ${silence_remnants} + Should Be True ${remnant_count} >= 1 + ... Expected a soft-deleted leading_silence remnant; found none (silence not trimmed) diff --git a/tests/integration/websocket_streaming_tests.robot b/tests/integration/websocket_streaming_tests.robot index 63baadf8..c5e80ec1 100644 --- a/tests/integration/websocket_streaming_tests.robot +++ b/tests/integration/websocket_streaming_tests.robot @@ -34,12 +34,12 @@ Streaming jobs created on stream start Sleep 2s # Check speech detection job ${jobs}= Get Jobs By Type speech_detection - Should Not Be Empty ${jobs} + Should Not Be Empty ${jobs} ${speech_job}= Find Job For Client ${jobs} ${device_name} Should Not Be Equal ${speech_job} ${None} Speech detection job not created # Check audio persistence job - ${persist_job}= Find Job For Client ${jobs} ${device_name} + ${persist_job}= Find Job For Client ${jobs} ${device_name} Should Not Be Equal ${persist_job} ${None} Audio persistence job not created Log Both jobs active during streaming @@ -73,7 +73,9 @@ Conversation Job Created After Speech Detection # Wait for open_conversation job to be created (transcription + speech analysis takes time) # Deepgram/OpenAI API calls + job started can take 30-60s with queue - Wait Until Keyword Succeeds 60s 3s + # Use 120s timeout: previous test's stream cleanup can trigger post-conversation jobs + # that occupy workers, delaying this test's speech detection pipeline + Wait Until Keyword Succeeds 120s 3s ... Job Type Exists For Client open_conversation ${client_id} Log To Console Open conversation job created after speech detection @@ -102,6 +104,44 @@ Conversation Job Created After Speech Detection Log Closed stream, sent ${total_chunks} total chunks +Button Press Should Close Active Conversation + [Documentation] Verify that a button double press during an active conversation + ... closes it with end_reason=close_requested and triggers post-processing. + ... (Double press: the button_control plugin maps single_press to + ... stop_playback and double_press to close_conversation — see + ... plugins/button_control/config.yml.) + [Tags] audio-streaming conversation + [Timeout] 120s + + # Arrange: Open stream and get enough speech to start a conversation + ${device_name}= Set Variable ws-button-close + ${stream_id}= Open Audio Stream device_name=${device_name} + ${client_id}= Get Client ID From Device Name ${device_name} + + # Get baseline conversation jobs + ${baseline_jobs}= Get Jobs By Type And Client open_conversation ${client_id} + ${baseline_count}= Get Length ${baseline_jobs} + + # Send audio with speech (realtime pacing for Deepgram to finalize segments) + Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=200 realtime_pacing=True + + # Wait for open_conversation_job to start (speech detected -> conversation opened) + ${jobs}= Wait Until Keyword Succeeds 60s 3s + ... Wait For New Job To Appear open_conversation ${client_id} ${baseline_count} + ${conversation_id}= Evaluate $jobs[0]['meta'].get('conversation_id', '') + Should Not Be Empty ${conversation_id} msg=Conversation ID not found in job meta + + # Act: Send button double press to close the conversation + # (single press is mapped to stop_playback since the button_control rework) + Send Button Event To Stream ${stream_id} DOUBLE_PRESS + + # Assert: Conversation should close with end_reason=close_requested + Wait Until Keyword Succeeds 30s 2s + ... Conversation Should Have End Reason ${conversation_id} close_requested + + # Cleanup + [Teardown] Run Keyword And Ignore Error Close Audio Stream ${stream_id} + Conversation Closes On Inactivity Timeout And Restarts Speech Detection [Documentation] Verify that after SPEECH_INACTIVITY_THRESHOLD_SECONDS of silence (audio time), ... the open_conversation job closes with timeout_triggered=True, @@ -170,4 +210,89 @@ Conversation Closes On Inactivity Timeout And Restarts Speech Detection Log To Console Memory jobs found: ${memory_jobs.__len__()} +Live Transcript Available During Active Recording + [Documentation] Verify that conversation has live-v0 transcript version + ... while audio is still being streamed. The monitoring loop + ... should create and update the live version in MongoDB. + ... + ... Strategy: Send audio fast (no realtime pacing) to quickly + ... trigger speech detection and conversation creation. Then keep + ... sending small batches to keep the conversation alive while + ... checking for live-v0 transcript in the database. + [Tags] audio-streaming conversation requires-api-keys + [Timeout] 180s + + ${device_name}= Set Variable ws-live-tx + ${stream_id}= Open Audio Stream device_name=${device_name} + ${client_id}= Get Client ID From Device Name ${device_name} + + # Send initial batch fast to trigger speech detection quickly + Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=200 + + # Wait for conversation to be created (speech detection → open_conversation_job) + ${jobs}= Wait Until Keyword Succeeds 60s 3s + ... Job Type Exists For Client open_conversation ${client_id} + ${conv_meta}= Set Variable ${jobs}[0][meta] + ${conversation_id}= Evaluate $conv_meta.get('conversation_id', '') + Should Not Be Empty ${conversation_id} msg=Conversation ID not found in job meta + + # Keep conversation alive by sending more audio while we check for live-v0 + # Send audio in small bursts with polling between + FOR ${i} IN RANGE 10 + # Send more audio to keep conversation active (prevents inactivity timeout) + Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} num_chunks=50 + + # Check if live transcript has appeared + ${status}= Run Keyword And Return Status + ... Conversation Should Have Live Transcript ${conversation_id} + IF ${status} + ${conversation}= Conversation Should Have Live Transcript ${conversation_id} + Log To Console Live transcript found on iteration ${i} + + # Verify transcript has actual content + Should Not Be Empty ${conversation}[transcript] + ... Live transcript should have text content + + Log To Console Live transcript verified: ${conversation}[transcript][:80]... + BREAK + END + Sleep 2s + END + + Should Be True ${status} Live transcript (live-v0) was never created during active recording + + [Teardown] Run Keyword And Ignore Error Close Audio Stream ${stream_id} + + +API Close Request Stops Active Conversation + [Documentation] Verify that POST /api/conversations/{client_id}/close + ... stops an active streaming conversation with end_reason=close_requested + [Tags] audio-streaming conversation requires-api-keys + [Timeout] 120s + + ${device_name}= Set Variable ws-api-close + ${stream_id}= Open Audio Stream device_name=${device_name} + ${client_id}= Get Client ID From Device Name ${device_name} + + # Get baseline conversation jobs + ${baseline_jobs}= Get Jobs By Type And Client open_conversation ${client_id} + ${baseline_count}= Get Length ${baseline_jobs} + + # Send audio to create conversation + Send Audio Chunks To Stream ${stream_id} ${TEST_AUDIO_FILE} + ... num_chunks=200 realtime_pacing=True + + # Wait for conversation job + ${jobs}= Wait Until Keyword Succeeds 60s 3s + ... Wait For New Job To Appear open_conversation ${client_id} ${baseline_count} + ${conversation_id}= Evaluate $jobs[0]['meta'].get('conversation_id', '') + Should Not Be Empty ${conversation_id} msg=Conversation ID not found in job meta + + # Close via API (POST directly on the admin session) + ${response}= POST On Session api /api/conversations/${client_id}/close expected_status=200 + + # Verify end_reason + Wait Until Keyword Succeeds 30s 2s + ... Conversation Should Have End Reason ${conversation_id} close_requested + [Teardown] Run Keyword And Ignore Error Close Audio Stream ${stream_id} diff --git a/tests/integration/websocket_transcription_e2e_test.robot b/tests/integration/websocket_transcription_e2e_test.robot index 3711ac54..bb7547c2 100644 --- a/tests/integration/websocket_transcription_e2e_test.robot +++ b/tests/integration/websocket_transcription_e2e_test.robot @@ -53,16 +53,13 @@ WebSocket Stream Produces Final Transcripts In Redis Log Closing stream - should trigger: end_marker → CloseStream → final results Close Audio Stream ${stream_id} - # Allow time for streaming consumer to process end_marker and get final results - Sleep 5s - - # Verify Redis stream transcription:results:{client_id} has entries + # Wait for streaming consumer to process end_marker and write final results to Redis + # Use retry loop instead of fixed sleep - consumer processing time varies ${stream_name}= Set Variable transcription:results:${client_id} - ${stream_length}= Redis Command XLEN ${stream_name} - - Should Be True ${stream_length} > 0 - ... Redis stream ${stream_name} is empty - no final transcripts received! This means end_marker was not sent or CloseStream failed. + Wait Until Keyword Succeeds 30s 2s + ... Redis Stream Should Not Be Empty ${stream_name} + ${stream_length}= Redis Command XLEN ${stream_name} Log ✅ Redis stream has ${stream_length} final transcript(s) @@ -195,22 +192,19 @@ Stream Close Sends End Marker To Redis Stream # Close stream - this MUST send end_marker Close Audio Stream ${stream_id} - # Allow time for end_marker to be written - Sleep 2s - - # Read all messages from audio stream to find end_marker - # Note: Redis Command returns string output from redis-cli, not a list - ${xrange_output}= Redis Command XRANGE ${audio_stream_name} - + - - # Search for end_marker in the redis-cli output string - # redis-cli XRANGE returns text with field names, so we just check if end_marker appears - ${found_end_marker}= Run Keyword And Return Status - ... Should Contain ${xrange_output} end_marker - ... ignore_case=True + # The audio stream is intentionally deleted ~1.2s after close by _try_delete_finished_stream(), + # so we can't rely on XRANGE to find end_marker. Instead, verify via transcription:complete + # which is a durable key (5-min TTL) set by StreamingTranscriptionConsumer.end_session_stream() + # only AFTER it processes the end_marker. + ${completion_key}= Set Variable transcription:complete:${client_id} + Wait Until Keyword Succeeds 30s 1s + ... Verify Redis Key Exists ${completion_key} - Should Be True ${found_end_marker} end_marker NOT found in Redis stream ${audio_stream_name}! Producer.finalize_session() did not send end_marker. XRANGE output: ${xrange_output} + ${signal_value}= Redis Command GET ${completion_key} + Should Be Equal As Strings ${signal_value} 1 + ... Completion signal should be "1" (clean close), got: ${signal_value} - Log ✅ end_marker successfully sent to Redis stream + Log ✅ end_marker was processed by streaming consumer (transcription:complete=${signal_value}) Streaming Consumer Closes Deepgram Connection On End Marker @@ -387,3 +381,13 @@ Streaming Completion Signal Is Set Before Transcript Read Log ✅ Completion signal ${completion_key} = ${signal_value} (consumer completed before job reads) +*** Keywords *** + +Redis Stream Should Not Be Empty + [Documentation] Assert that a Redis stream has at least one entry. + ... Used with Wait Until Keyword Succeeds for retry-based checks. + [Arguments] ${stream_name} + + ${stream_length}= Redis Command XLEN ${stream_name} + Should Be True ${stream_length} > 0 + ... Redis stream ${stream_name} is empty - no final transcripts received! This means end_marker was not sent or CloseStream failed. diff --git a/tests/libs/ConfigTestHelper.py b/tests/libs/ConfigTestHelper.py new file mode 100644 index 00000000..c0a95233 --- /dev/null +++ b/tests/libs/ConfigTestHelper.py @@ -0,0 +1,77 @@ +import os +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional +from unittest.mock import patch +from urllib.parse import urlparse + +import yaml +from omegaconf import OmegaConf + +# Add repo root to path to import config_manager +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) +from config_manager import ConfigManager + + +class ConfigTestHelper: + """Helper library for testing configuration logic.""" + + def _to_dict(self, obj: Any) -> Any: + """Recursively converts Robot Framework DotDict to standard dict.""" + if isinstance(obj, dict): + return {k: self._to_dict(v) for k, v in obj.items()} + if isinstance(obj, list): + return [self._to_dict(v) for v in obj] + return obj + + def resolve_omega_config( + self, config_template: Dict[str, Any], env_vars: Dict[str, str] + ) -> Dict[str, Any]: + """ + Resolves an OmegaConf configuration template with provided environment variables. + """ + config_template = self._to_dict(config_template) + # We need to ensure values are strings for os.environ + str_env_vars = {k: str(v) for k, v in env_vars.items()} + + with patch.dict(os.environ, str_env_vars): + conf = OmegaConf.create(config_template) + resolved = OmegaConf.to_container(conf, resolve=True) + return resolved + + def check_url_parsing(self, url: str) -> Dict[str, Any]: + """ + Parses a URL and returns its components to verify correct parsing. + """ + parsed = urlparse(url) + return {"scheme": parsed.scheme, "netloc": parsed.netloc, "path": parsed.path} + + def create_temp_config_structure( + self, base_path: str, content: Dict[str, Any] + ) -> str: + """ + Creates the config folder structure and config.yml within the given base path. + """ + content = self._to_dict(content) + path = Path(base_path) / "config" + path.mkdir(parents=True, exist_ok=True) + config_file = path / "config.yml" + with open(config_file, "w") as f: + yaml.dump(content, f, default_flow_style=False, sort_keys=False) + return str(base_path) + + def get_config_manager_instance(self, repo_root: str) -> ConfigManager: + """Returns a ConfigManager instance configured with the given repo_root.""" + return ConfigManager(service_path=None, repo_root=Path(repo_root)) + + def add_model_to_config_manager(self, cm: ConfigManager, model_def: Dict[str, Any]): + """Wrapper for add_or_update_model that converts arguments.""" + model_def = self._to_dict(model_def) + cm.add_or_update_model(model_def) + + def update_defaults_in_config_manager( + self, cm: ConfigManager, updates: Dict[str, str] + ): + """Wrapper for update_config_defaults that converts arguments.""" + updates = self._to_dict(updates) + cm.update_config_defaults(updates) diff --git a/tests/libs/audio_stream_library.py b/tests/libs/audio_stream_library.py index e14a174e..ab16614e 100644 --- a/tests/libs/audio_stream_library.py +++ b/tests/libs/audio_stream_library.py @@ -17,6 +17,7 @@ Stop Audio Stream ${stream_id} """ +import asyncio import sys from pathlib import Path from typing import Optional @@ -27,7 +28,10 @@ sys.path.insert(0, str(backend_src)) from advanced_omi_backend.clients import AudioStreamClient -from advanced_omi_backend.clients.audio_stream_client import StreamManager, stream_audio_file as _stream_audio_file +from advanced_omi_backend.clients.audio_stream_client import StreamManager +from advanced_omi_backend.clients.audio_stream_client import ( + stream_audio_file as _stream_audio_file, +) # Module-level manager for non-blocking streams _manager = StreamManager() @@ -37,6 +41,7 @@ # Blocking Mode (simple, streams entire file) # ============================================================================= + def stream_audio_file( base_url: str, token: str, @@ -60,6 +65,7 @@ def stream_audio_file( # Non-blocking Mode (for testing during stream) # ============================================================================= + def start_audio_stream( base_url: str, token: str, @@ -113,8 +119,6 @@ def send_audio_stop_event(stream_id: str) -> None: if not session: raise ValueError(f"Stream {stream_id} not found") - import asyncio - async def _send_stop(): try: await session.client.send_audio_stop() @@ -145,6 +149,16 @@ def close_audio_stream_without_stop(stream_id: str) -> int: return _manager.close_stream_without_stop(stream_id) +def send_button_event(stream_id: str, button_state: str = "SINGLE_PRESS") -> None: + """Send a button event to an open stream. + + Args: + stream_id: Stream session ID + button_state: Button state ("SINGLE_PRESS" or "DOUBLE_PRESS") + """ + _manager.send_button_event(stream_id, button_state) + + def cleanup_all_streams(): """Stop all active streams.""" _manager.cleanup_all() @@ -154,6 +168,7 @@ def cleanup_all_streams(): # Advanced Usage # ============================================================================= + def get_audio_stream_client( base_url: str, token: str, diff --git a/tests/libs/auth_helpers.py b/tests/libs/auth_helpers.py index e9625d85..f4e6e8fa 100644 --- a/tests/libs/auth_helpers.py +++ b/tests/libs/auth_helpers.py @@ -20,21 +20,23 @@ def get_user_id_from_token(jwt_token: str) -> str: ${user_id}= Get User ID From Token ${token} """ # Split token into parts - parts = jwt_token.split('.') + parts = jwt_token.split(".") if len(parts) != 3: - raise ValueError(f"Invalid JWT token format: expected 3 parts, got {len(parts)}") + raise ValueError( + f"Invalid JWT token format: expected 3 parts, got {len(parts)}" + ) # Decode payload (add padding if needed) payload_b64 = parts[1] padding = (4 - len(payload_b64) % 4) % 4 - payload_b64_padded = payload_b64 + ('=' * padding) + payload_b64_padded = payload_b64 + ("=" * padding) # Base64 decode and parse JSON payload_bytes = base64.urlsafe_b64decode(payload_b64_padded) - payload = json.loads(payload_bytes.decode('utf-8')) + payload = json.loads(payload_bytes.decode("utf-8")) # Extract user ID from 'sub' field - user_id = payload.get('sub') + user_id = payload.get("sub") if not user_id: raise ValueError("Token payload does not contain 'sub' field") diff --git a/tests/libs/mock_asr_server.py b/tests/libs/mock_asr_server.py index d03c0c1b..d33f217c 100644 --- a/tests/libs/mock_asr_server.py +++ b/tests/libs/mock_asr_server.py @@ -24,12 +24,11 @@ from typing import Optional import uvicorn -from fastapi import FastAPI, File, UploadFile, HTTPException +from fastapi import FastAPI, File, HTTPException, UploadFile from pydantic import BaseModel logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @@ -77,7 +76,7 @@ class TranscriptionResult(BaseModel): "has_word_timestamps": True, }, "vibevoice": { - "model_id": "microsoft/VibeVoice-ASR", + "model_id": "microsoft/VibeVoice-ASR-HF", "provider": "vibevoice", "capabilities": ["segments", "diarization", "timestamps"], "has_diarization": True, @@ -120,7 +119,12 @@ def get_provider_config(): # Mock diarized segments (for providers with built-in diarization) MOCK_DIARIZED_SEGMENTS = [ - {"speaker": "Speaker 0", "start": 0.0, "end": 1.2, "text": "This is a mock transcription"}, + { + "speaker": "Speaker 0", + "start": 0.0, + "end": 1.2, + "text": "This is a mock transcription", + }, {"speaker": "Speaker 1", "start": 1.25, "end": 2.2, "text": "for testing purposes"}, ] @@ -131,9 +135,7 @@ async def health(): config = get_provider_config() logger.debug(f"Health check requested (provider: {config['provider']})") return HealthResponse( - status="healthy", - model=config["model_id"], - provider=config["provider"] + status="healthy", model=config["model_id"], provider=config["provider"] ) @@ -146,7 +148,7 @@ async def info(): model_id=config["model_id"], provider=config["provider"], capabilities=config["capabilities"], - supported_languages=None + supported_languages=None, ) @@ -165,7 +167,9 @@ async def transcribe(file: UploadFile = File(...)): file_size = len(content) config = get_provider_config() - logger.info(f"Received file: {file.filename}, size: {file_size} bytes (provider: {config['provider']})") + logger.info( + f"Received file: {file.filename}, size: {file_size} bytes (provider: {config['provider']})" + ) # Basic validation - reject empty files if file_size == 0: @@ -181,11 +185,7 @@ async def transcribe(file: UploadFile = File(...)): if config["has_diarization"]: segments = MOCK_DIARIZED_SEGMENTS - return TranscriptionResult( - text=MOCK_TRANSCRIPT, - words=words, - segments=segments - ) + return TranscriptionResult(text=MOCK_TRANSCRIPT, words=words, segments=segments) def main(host: str, port: int, provider: str = "mock", debug: bool = False): @@ -203,23 +203,22 @@ def main(host: str, port: int, provider: str = "mock", debug: bool = False): logger.info(f"Has word timestamps: {config['has_word_timestamps']}") logger.info("Endpoints: /health, /info, /transcribe") - uvicorn.run( - app, - host=host, - port=port, - log_level=log_level - ) + uvicorn.run(app, host=host, port=port, log_level=log_level) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Mock ASR Server") - parser.add_argument("--host", default="0.0.0.0", help="Server host (default: 0.0.0.0)") - parser.add_argument("--port", type=int, default=8765, help="Server port (default: 8765)") + parser.add_argument( + "--host", default="0.0.0.0", help="Server host (default: 0.0.0.0)" + ) + parser.add_argument( + "--port", type=int, default=8765, help="Server port (default: 8765)" + ) parser.add_argument( "--provider", default="mock", choices=["mock", "parakeet", "vibevoice", "deepgram"], - help="Provider mode - determines reported capabilities (default: mock)" + help="Provider mode - determines reported capabilities (default: mock)", ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") diff --git a/tests/libs/mock_llm_server.py b/tests/libs/mock_llm_server.py index e00b0b06..ba6688ab 100755 --- a/tests/libs/mock_llm_server.py +++ b/tests/libs/mock_llm_server.py @@ -14,18 +14,18 @@ - Memory updates: system prompt contains "UPDATE_MEMORY_PROMPT" or "memory manager" """ +import argparse import asyncio +import hashlib import json import logging -import argparse -import hashlib from typing import List -from aiohttp import web + import numpy as np +from aiohttp import web logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @@ -38,8 +38,8 @@ def generate_deterministic_embedding(text: str, dimensions: int = 1536) -> List[ Generates unit vector for cosine similarity compatibility. """ # Use SHA-256 hash as seed - hash_bytes = hashlib.sha256(text.encode('utf-8')).digest() - seed = int.from_bytes(hash_bytes[:4], 'big') + hash_bytes = hashlib.sha256(text.encode("utf-8")).digest() + seed = int.from_bytes(hash_bytes[:4], "big") # Generate reproducible random vector rng = np.random.default_rng(seed) @@ -83,7 +83,7 @@ def create_fact_extraction_response() -> dict: "User met with John", "Discussed project timeline", "User prefers morning meetings", - "User is working on Chronicle project" + "User is working on Chronicle project", ] content = json.dumps({"facts": facts}) @@ -93,19 +93,14 @@ def create_fact_extraction_response() -> dict: "object": "chat.completion", "created": 1234567890, "model": "gpt-4o-mini", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": content - }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 100, - "completion_tokens": 50, - "total_tokens": 150 - } + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, } @@ -136,19 +131,14 @@ def create_memory_update_response() -> dict: "object": "chat.completion", "created": 1234567890, "model": "gpt-4o-mini", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": xml_content - }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 150, - "completion_tokens": 80, - "total_tokens": 230 - } + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": xml_content}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 150, "completion_tokens": 80, "total_tokens": 230}, } @@ -161,19 +151,14 @@ def create_general_response(user_message: str) -> dict: "object": "chat.completion", "created": 1234567890, "model": "gpt-4o-mini", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": response_text - }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 50, - "completion_tokens": 20, - "total_tokens": 70 - } + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": response_text}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 50, "completion_tokens": 20, "total_tokens": 70}, } @@ -206,8 +191,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response: except Exception as e: logger.error(f"Error handling chat completions: {e}", exc_info=True) return web.json_response( - {"error": {"message": str(e), "type": "server_error"}}, - status=500 + {"error": {"message": str(e), "type": "server_error"}}, status=500 ) @@ -225,11 +209,9 @@ async def handle_embeddings(request: web.Request) -> web.Response: embeddings_data = [] for idx, text in enumerate(input_texts): embedding = generate_deterministic_embedding(text, dimensions=1536) - embeddings_data.append({ - "object": "embedding", - "embedding": embedding, - "index": idx - }) + embeddings_data.append( + {"object": "embedding", "embedding": embedding, "index": idx} + ) logger.info(f"Generated {len(embeddings_data)} embeddings") @@ -239,8 +221,8 @@ async def handle_embeddings(request: web.Request) -> web.Response: "model": "text-embedding-3-small", "usage": { "prompt_tokens": len(input_texts) * 10, - "total_tokens": len(input_texts) * 10 - } + "total_tokens": len(input_texts) * 10, + }, } return web.json_response(response) @@ -248,8 +230,7 @@ async def handle_embeddings(request: web.Request) -> web.Response: except Exception as e: logger.error(f"Error handling embeddings: {e}", exc_info=True) return web.json_response( - {"error": {"message": str(e), "type": "server_error"}}, - status=500 + {"error": {"message": str(e), "type": "server_error"}}, status=500 ) @@ -262,15 +243,15 @@ async def handle_models(request: web.Request) -> web.Response: "id": "gpt-4o-mini", "object": "model", "created": 1234567890, - "owned_by": "mock-llm" + "owned_by": "mock-llm", }, { "id": "text-embedding-3-small", "object": "model", "created": 1234567890, - "owned_by": "mock-llm" - } - ] + "owned_by": "mock-llm", + }, + ], } logger.info("Returning available models") @@ -287,12 +268,12 @@ def create_app() -> web.Application: app = web.Application() # OpenAI-compatible routes - app.router.add_post('/v1/chat/completions', handle_chat_completions) - app.router.add_post('/v1/embeddings', handle_embeddings) - app.router.add_get('/v1/models', handle_models) + app.router.add_post("/v1/chat/completions", handle_chat_completions) + app.router.add_post("/v1/embeddings", handle_embeddings) + app.router.add_get("/v1/models", handle_models) # Health check - app.router.add_get('/health', handle_health) + app.router.add_get("/health", handle_health) return app @@ -313,8 +294,12 @@ def main(host: str, port: int): if __name__ == "__main__": parser = argparse.ArgumentParser(description="Mock LLM Server") - parser.add_argument("--host", default="0.0.0.0", help="Server host (default: 0.0.0.0)") - parser.add_argument("--port", type=int, default=11435, help="Server port (default: 11435)") + parser.add_argument( + "--host", default="0.0.0.0", help="Server host (default: 0.0.0.0)" + ) + parser.add_argument( + "--port", type=int, default=11435, help="Server port (default: 11435)" + ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() diff --git a/tests/libs/mock_streaming_stt_server.py b/tests/libs/mock_streaming_stt_server.py index 65c02f93..b5f54865 100755 --- a/tests/libs/mock_streaming_stt_server.py +++ b/tests/libs/mock_streaming_stt_server.py @@ -16,17 +16,17 @@ - This matches production behavior and tests will catch offset accumulation bugs """ +import argparse import asyncio import json import logging -import argparse from typing import Optional + import websockets from websockets.server import WebSocketServerProtocol logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @@ -40,7 +40,7 @@ def create_deepgram_response( is_final: bool, words: Optional[list] = None, confidence: float = 0.99, - cumulative_offset: float = 0.0 + cumulative_offset: float = 0.0, ) -> tuple[dict, float]: """ Create Deepgram-compatible nested response format. @@ -69,12 +69,15 @@ def create_deepgram_response( words = [] local_time = 0.0 for word in transcript.split(): - words.append({ - "word": word, - "start": cumulative_offset + local_time, # Cumulative from stream start - "end": cumulative_offset + local_time + 0.3, - "confidence": confidence - }) + words.append( + { + "word": word, + "start": cumulative_offset + + local_time, # Cumulative from stream start + "end": cumulative_offset + local_time + 0.3, + "confidence": confidence, + } + ) local_time += 0.35 # 0.3s word + 0.05s gap # Update cumulative offset for next chunk @@ -84,12 +87,10 @@ def create_deepgram_response( "type": "Results", "is_final": is_final, "channel": { - "alternatives": [{ - "transcript": transcript, - "confidence": confidence, - "words": words - }] - } + "alternatives": [ + {"transcript": transcript, "confidence": confidence, "words": words} + ] + }, } return response, new_offset @@ -114,12 +115,14 @@ def create_final_response(cumulative_offset: float = 0.0) -> tuple[dict, float]: local_time = 0.0 for word in transcript_words: - words.append({ - "word": word, - "start": cumulative_offset + local_time, # Cumulative from stream start - "end": cumulative_offset + local_time + 0.35, - "confidence": 0.99 - }) + words.append( + { + "word": word, + "start": cumulative_offset + local_time, # Cumulative from stream start + "end": cumulative_offset + local_time + 0.35, + "confidence": 0.99, + } + ) local_time += 0.4 # 0.35s word + 0.05s gap # Final timestamp should be >2.0s from start of this segment @@ -132,12 +135,10 @@ def create_final_response(cumulative_offset: float = 0.0) -> tuple[dict, float]: "type": "Results", "is_final": True, "channel": { - "alternatives": [{ - "transcript": transcript, - "confidence": 0.99, - "words": words - }] - } + "alternatives": [ + {"transcript": transcript, "confidence": 0.99, "words": words} + ] + }, } return response, new_offset @@ -155,7 +156,9 @@ async def handle_client(websocket: WebSocketServerProtocol): try: # Send initial empty result (no timestamp advancement for empty result) - initial, _ = create_deepgram_response(transcript="", is_final=False, cumulative_offset=0.0) + initial, _ = create_deepgram_response( + transcript="", is_final=False, cumulative_offset=0.0 + ) await websocket.send(json.dumps(initial)) logger.debug(f"Sent initial result to {client_id}") @@ -169,22 +172,28 @@ async def handle_client(websocket: WebSocketServerProtocol): # Speech detection relies on is_final=True results stored in transcription:results stream if chunk_count % 50 == 0: current_offset = connection_state[client_id]["cumulative_offset"] - final, new_offset = create_final_response(cumulative_offset=current_offset) + final, new_offset = create_final_response( + cumulative_offset=current_offset + ) connection_state[client_id]["cumulative_offset"] = new_offset await websocket.send(json.dumps(final)) - logger.info(f"Sent periodic final result to {client_id} (offset: {current_offset:.2f}s → {new_offset:.2f}s)") + logger.info( + f"Sent periodic final result to {client_id} (offset: {current_offset:.2f}s → {new_offset:.2f}s)" + ) # Send interim results every 10 chunks elif chunk_count % 10 == 0: current_offset = connection_state[client_id]["cumulative_offset"] interim, new_offset = create_deepgram_response( transcript=f"This is interim transcription for chunk {chunk_count // 10}", is_final=False, - cumulative_offset=current_offset + cumulative_offset=current_offset, ) # Update cumulative offset for next chunk connection_state[client_id]["cumulative_offset"] = new_offset await websocket.send(json.dumps(interim)) - logger.debug(f"Sent interim result to {client_id} (offset: {current_offset:.2f}s → {new_offset:.2f}s)") + logger.debug( + f"Sent interim result to {client_id} (offset: {current_offset:.2f}s → {new_offset:.2f}s)" + ) # Handle control messages elif isinstance(message, str): @@ -196,17 +205,25 @@ async def handle_client(websocket: WebSocketServerProtocol): logger.info(f"Received CloseStream from {client_id}") # Send final result with cumulative timestamps - current_offset = connection_state[client_id]["cumulative_offset"] - final, new_offset = create_final_response(cumulative_offset=current_offset) + current_offset = connection_state[client_id][ + "cumulative_offset" + ] + final, new_offset = create_final_response( + cumulative_offset=current_offset + ) await websocket.send(json.dumps(final)) - logger.info(f"Sent final result to {client_id} (offset: {current_offset:.2f}s → {new_offset:.2f}s): {final['channel']['alternatives'][0]['transcript']}") + logger.info( + f"Sent final result to {client_id} (offset: {current_offset:.2f}s → {new_offset:.2f}s): {final['channel']['alternatives'][0]['transcript']}" + ) # Close connection gracefully await websocket.close() break else: - logger.warning(f"Unknown message type from {client_id}: {msg_type}") + logger.warning( + f"Unknown message type from {client_id}: {msg_type}" + ) except json.JSONDecodeError: logger.error(f"Invalid JSON from {client_id}: {message}") @@ -222,9 +239,13 @@ async def handle_client(websocket: WebSocketServerProtocol): if client_id in connection_state: final_offset = connection_state[client_id]["cumulative_offset"] del connection_state[client_id] - logger.info(f"Connection closed: {client_id}, processed {chunk_count} chunks, final cumulative offset: {final_offset:.2f}s") + logger.info( + f"Connection closed: {client_id}, processed {chunk_count} chunks, final cumulative offset: {final_offset:.2f}s" + ) else: - logger.info(f"Connection closed: {client_id}, processed {chunk_count} chunks") + logger.info( + f"Connection closed: {client_id}, processed {chunk_count} chunks" + ) async def main(host: str, port: int): @@ -240,8 +261,12 @@ async def main(host: str, port: int): if __name__ == "__main__": parser = argparse.ArgumentParser(description="Mock Streaming STT Server") - parser.add_argument("--host", default="0.0.0.0", help="Server host (default: 0.0.0.0)") - parser.add_argument("--port", type=int, default=9999, help="Server port (default: 9999)") + parser.add_argument( + "--host", default="0.0.0.0", help="Server host (default: 0.0.0.0)" + ) + parser.add_argument( + "--port", type=int, default=9999, help="Server port (default: 9999)" + ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() diff --git a/tests/libs/mongodb_helper.py b/tests/libs/mongodb_helper.py index aaa91cd0..59a59cdf 100644 --- a/tests/libs/mongodb_helper.py +++ b/tests/libs/mongodb_helper.py @@ -6,8 +6,9 @@ import os from pathlib import Path -from pymongo import MongoClient + from dotenv import load_dotenv +from pymongo import MongoClient # Load test environment variables tests_dir = Path(__file__).parent.parent @@ -39,10 +40,11 @@ def get_audio_chunks(conversation_id): try: # Query audio_chunks collection - chunks = list(db.audio_chunks.find( - {"conversation_id": conversation_id}, - sort=[("chunk_index", 1)] - )) + chunks = list( + db.audio_chunks.find( + {"conversation_id": conversation_id}, sort=[("chunk_index", 1)] + ) + ) # Convert ObjectId to string and Binary to bytes length for Robot Framework for chunk in chunks: @@ -104,3 +106,57 @@ def verify_chunks_exist(conversation_id, min_chunks=1): ) return True + + +def _active_transcript_chars(conv): + """Length of the active transcript version's text (handles list schema).""" + active = conv.get("active_transcript_version") + for version in conv.get("transcript_versions") or []: + if version.get("version_id") == active: + return len(version.get("transcript") or version.get("text") or "") + return 0 + + +def find_client_conversations(client_id, include_deleted=True): + """All conversations for a client (optionally including soft-deleted ones). + + Returns a flat list of dicts with the fields the reconnect/silence e2e asserts + on: conversation_id, deleted, deletion_reason, end_reason, audio_chunks_count, + audio_total_duration, and transcript_chars (active version text length). The API + hides soft-deleted conversations, so these are read straight from Mongo. + """ + client = MongoClient(get_mongodb_uri()) + db = client[get_db_name()] + try: + query = {"client_id": client_id} + if not include_deleted: + query["deleted"] = {"$ne": True} + out = [] + for conv in db.conversations.find(query): + out.append( + { + "conversation_id": conv.get("conversation_id"), + "deleted": bool(conv.get("deleted")), + "deletion_reason": conv.get("deletion_reason") or "", + "end_reason": conv.get("end_reason") or "", + "always_persist": bool(conv.get("always_persist")), + "audio_chunks_count": conv.get("audio_chunks_count"), + "audio_total_duration": conv.get("audio_total_duration"), + "transcript_chars": _active_transcript_chars(conv), + } + ) + return out + finally: + client.close() + + +def count_orphaned_transcripts(client_id): + """Conversations soft-deleted as audio_chunks_not_ready that still carry a + transcript — the exact data-loss the reconnect fix prevents. Should be 0.""" + return sum( + 1 + for c in find_client_conversations(client_id, include_deleted=True) + if c["deleted"] + and c["deletion_reason"] == "audio_chunks_not_ready" + and c["transcript_chars"] > 0 + ) diff --git a/tests/mocks/mock_speaker_client.py b/tests/mocks/mock_speaker_client.py index e53a556e..ae595876 100644 --- a/tests/mocks/mock_speaker_client.py +++ b/tests/mocks/mock_speaker_client.py @@ -33,7 +33,7 @@ class MockSpeakerRecognitionClient: "speaker": 0, "identified_as": "Unknown", "text": "The pumpkin that'll last for forever. Finally. Does it count? Today, we're taking a glass blowing class.", - "confidence": 0.95 + "confidence": 0.95, }, { "start": 10.28, @@ -41,7 +41,7 @@ class MockSpeakerRecognitionClient: "speaker": 0, "identified_as": "Unknown", "text": "I'm sweating already. We've worked with a lot of materials before, but we've only scratched the surface", - "confidence": 0.93 + "confidence": 0.93, }, { "start": 20.455, @@ -49,7 +49,7 @@ class MockSpeakerRecognitionClient: "speaker": 1, "identified_as": "Unknown", "text": "when it comes to glass", - "confidence": 0.91 + "confidence": 0.91, }, { "start": 22.095, @@ -57,7 +57,7 @@ class MockSpeakerRecognitionClient: "speaker": 0, "identified_as": "Unknown", "text": "and that's because", - "confidence": 0.94 + "confidence": 0.94, }, { "start": 23.815, @@ -65,7 +65,7 @@ class MockSpeakerRecognitionClient: "speaker": 1, "identified_as": "Unknown", "text": "a little intimidating. We've got about 400 pounds", - "confidence": 0.92 + "confidence": 0.92, }, { "start": 28.335, @@ -73,7 +73,7 @@ class MockSpeakerRecognitionClient: "speaker": 0, "identified_as": "Unknown", "text": "of liquid glass in this furnace right here. Nick's gonna really help us out. Nick, I'm excited and nervous. Me too.", - "confidence": 0.96 + "confidence": 0.96, }, { "start": 43.28, @@ -81,7 +81,7 @@ class MockSpeakerRecognitionClient: "speaker": 1, "identified_as": "Unknown", "text": "So we're gonna", - "confidence": 0.90 + "confidence": 0.90, }, { "start": 44.68, @@ -89,7 +89,7 @@ class MockSpeakerRecognitionClient: "speaker": 0, "identified_as": "Unknown", "text": "make what's called a trumpet", - "confidence": 0.95 + "confidence": 0.95, }, { "start": 46.96, @@ -97,8 +97,8 @@ class MockSpeakerRecognitionClient: "speaker": 0, "identified_as": "Unknown", "text": "flower. We're using gravity as a tool.", - "confidence": 0.93 - } + "confidence": 0.93, + }, ] } @@ -111,7 +111,7 @@ async def diarize_identify_match( conversation_id: str, backend_token: str, transcript_data: Dict, - user_id: Optional[str] = None + user_id: Optional[str] = None, ) -> Dict: """ Return pre-computed mock segments for known test audio files. @@ -125,7 +125,9 @@ async def diarize_identify_match( Returns: Dictionary with 'segments' array matching speaker service format """ - logger.info(f"🎤 Mock speaker client processing conversation: {conversation_id[:12]}...") + logger.info( + f"🎤 Mock speaker client processing conversation: {conversation_id[:12]}..." + ) # Try to identify which test audio this is from the transcript transcript_text = transcript_data.get("text", "").lower() @@ -135,11 +137,15 @@ async def diarize_identify_match( filename = "DIY_Experts_Glass_Blowing_16khz_mono_1min.wav" if filename in self.MOCK_SEGMENTS: segments = self.MOCK_SEGMENTS[filename] - logger.info(f"🎤 Mock returning {len(segments)} segments for DIY Glass Blowing audio") + logger.info( + f"🎤 Mock returning {len(segments)} segments for DIY Glass Blowing audio" + ) return {"segments": segments} # Fallback: Create single generic segment - logger.warning(f"🎤 Mock: No pre-computed segments found, creating generic segment") + logger.warning( + f"🎤 Mock: No pre-computed segments found, creating generic segment" + ) # Get duration from words if available words = transcript_data.get("words", []) @@ -149,12 +155,14 @@ async def diarize_identify_match( duration = 60.0 return { - "segments": [{ - "start": 0.0, - "end": duration, - "speaker": 0, - "identified_as": "Unknown", - "text": transcript_data.get("text", ""), - "confidence": 0.85 - }] + "segments": [ + { + "start": 0.0, + "end": duration, + "speaker": 0, + "identified_as": "Unknown", + "text": transcript_data.get("text", ""), + "confidence": 0.85, + } + ] } diff --git a/tests/resources/asr_keywords.robot b/tests/resources/asr_keywords.robot index 5cf95afe..fec7e031 100644 --- a/tests/resources/asr_keywords.robot +++ b/tests/resources/asr_keywords.robot @@ -139,4 +139,3 @@ Remove GPU ASR Service ELSE Log To Console ✅ ${service} removed END - diff --git a/tests/resources/audio_keywords.robot b/tests/resources/audio_keywords.robot index c752e511..8e14e1ce 100644 --- a/tests/resources/audio_keywords.robot +++ b/tests/resources/audio_keywords.robot @@ -17,7 +17,7 @@ Upload Audio File File Should Exist ${audio_file_path} # Debug the request being sent - + Log Sending file: ${audio_file_path} Log Device name: ${device_name} Log Folder: ${folder} diff --git a/tests/resources/chat_keywords.robot b/tests/resources/chat_keywords.robot index 38c2f39c..89d5bcf6 100644 --- a/tests/resources/chat_keywords.robot +++ b/tests/resources/chat_keywords.robot @@ -85,6 +85,3 @@ Send Chat Message ${response}= POST On Session api /api/chat/completions json=${body} expected_status=${expected_status} RETURN ${response} - - - diff --git a/tests/resources/conversation_keywords.robot b/tests/resources/conversation_keywords.robot index 9d393025..ea14d986 100644 --- a/tests/resources/conversation_keywords.robot +++ b/tests/resources/conversation_keywords.robot @@ -56,14 +56,14 @@ Get Conversation By ID Get Conversation Versions [Documentation] Get version history for a conversation [Arguments] ${conversation_id} - ${response}= GET On Session api /api/conversations/${conversation_id}/versions + ${response}= GET On Session api /api/conversations/${conversation_id}/versions RETURN ${response.json()}[transcript_versions] -Get conversation memory versions - [Documentation] Get memory version history for a conversation +Get Conversation Memory Audit + [Documentation] Get the memory vault change ledger (audit history) for a conversation [Arguments] ${conversation_id} - ${response}= GET On Session api /api/conversations/${conversation_id}/versions/memory - RETURN ${response.json()}[memory_versions] + ${response}= GET On Session api /api/conversations/${conversation_id}/memory-audit + RETURN ${response.json()}[entries] Reprocess Transcript [Documentation] Trigger transcript reprocessing for a conversation @@ -95,14 +95,7 @@ Activate Transcript Version [Documentation] Activate a specific transcript version [Arguments] ${conversation_id} ${version_id} - ${response}= POST On Session api /api/conversations/${conversation_id}/activate-transcript/${version_id} - RETURN ${response.json()} - -Activate Memory Version - [Documentation] Activate a specific memory version - [Arguments] ${conversation_id} ${version_id} - - ${response}= POST On Session api /api/conversations/${conversation_id}/activate-memory/${version_id} + ${response}= POST On Session api /api/conversations/${conversation_id}/activate-transcript/${version_id} RETURN ${response.json()} Delete Conversation @@ -220,7 +213,17 @@ Verify Conversation Processing Status Should Be Equal As Strings ${conversation}[processing_status] ${expected_status} ... Expected processing_status='${expected_status}', got '${conversation}[processing_status]' - Log ✅ Conversation ${conversation_id} has processing_status='${expected_status}' + Log ��� Conversation ${conversation_id} has processing_status='${expected_status}' + +Conversation Should Have Live Transcript + [Documentation] Verify conversation has active live-v0 transcript version with content. + ... Returns the conversation dict if successful. + [Arguments] ${conversation_id} + + ${conversation}= Get Conversation By ID ${conversation_id} + Should Be Equal As Strings ${conversation}[active_transcript_version] live-v0 + ... msg=Expected active_transcript_version='live-v0', got '${conversation}[active_transcript_version]' + RETURN ${conversation} Verify Conversation Always Persist Flag [Documentation] Verify conversation has always_persist=True diff --git a/tests/resources/mongodb_keywords.robot b/tests/resources/mongodb_keywords.robot index 58a1f991..4f8adf55 100644 --- a/tests/resources/mongodb_keywords.robot +++ b/tests/resources/mongodb_keywords.robot @@ -19,6 +19,25 @@ Get Audio Chunks For Conversation RETURN ${chunks} +Get Client Conversations Including Deleted + [Documentation] All conversations for a client (incl. soft-deleted) with + ... deletion_reason / audio fields / transcript char count. The API + ... hides soft-deleted conversations, so this reads from Mongo. + [Arguments] ${client_id} + + ${conversations}= Find Client Conversations ${client_id} + RETURN ${conversations} + + +Get Orphaned Transcript Count + [Documentation] Count conversations soft-deleted as audio_chunks_not_ready that + ... still carry a transcript (the data-loss the reconnect fix prevents). + [Arguments] ${client_id} + + ${count}= Count Orphaned Transcripts ${client_id} + RETURN ${count} + + Verify Audio Chunks Exist [Documentation] Verify that audio chunks exist in MongoDB for a conversation [Arguments] ${conversation_id} ${min_chunks}=1 diff --git a/tests/resources/plugin_keywords.robot b/tests/resources/plugin_keywords.robot index 4e8d52d0..6c20e7cb 100644 --- a/tests/resources/plugin_keywords.robot +++ b/tests/resources/plugin_keywords.robot @@ -66,7 +66,7 @@ Verify Event Matches Subscription Get Test Plugins Config Path [Documentation] Get path to test plugins configuration - RETURN ${CURDIR}/../../config/plugins.yml + RETURN ${CURDIR}/../config/plugins.test.yml Verify HA Plugin Uses Events [Documentation] Verify HomeAssistant plugin config uses event events diff --git a/tests/resources/queue_keywords.robot b/tests/resources/queue_keywords.robot index b6678bf7..1e8e5255 100644 --- a/tests/resources/queue_keywords.robot +++ b/tests/resources/queue_keywords.robot @@ -375,4 +375,4 @@ Get Job Result ${job_data}= Set Variable ${response.json()} ${result}= Set Variable ${job_data}[result] - RETURN ${result} \ No newline at end of file + RETURN ${result} diff --git a/tests/resources/redis_keywords.robot b/tests/resources/redis_keywords.robot index e6179afd..897872a1 100644 --- a/tests/resources/redis_keywords.robot +++ b/tests/resources/redis_keywords.robot @@ -24,7 +24,7 @@ Get Redis Session Data # Use redis-cli to get session hash ${redis_key}= Set Variable audio:session:${session_id} - ${result}= Run Process docker exec ${REDIS_CONTAINER} + ${result}= Run Process ${CONTAINER_ENGINE} exec ${REDIS_CONTAINER} ... redis-cli HGETALL ${redis_key} Should Be Equal As Integers ${result.rc} 0 @@ -81,7 +81,7 @@ Redis Command [Arguments] ${command} @{args} # Execute redis-cli command - ${result}= Run Process docker exec ${REDIS_CONTAINER} + ${result}= Run Process ${CONTAINER_ENGINE} exec ${REDIS_CONTAINER} ... redis-cli ${command} @{args} Should Be Equal As Integers ${result.rc} 0 @@ -127,7 +127,7 @@ Verify Conversation Current Key # Use KEYS pattern to find matching key (handles counter suffixes like -2, -3) ${pattern}= Set Variable conversation:current:${session_id}* - ${result}= Run Process docker exec ${REDIS_CONTAINER} + ${result}= Run Process ${CONTAINER_ENGINE} exec ${REDIS_CONTAINER} ... redis-cli KEYS ${pattern} Should Be Equal As Integers ${result.rc} 0 diff --git a/tests/resources/session_keywords.robot b/tests/resources/session_keywords.robot index bb4d4444..de892cf8 100644 --- a/tests/resources/session_keywords.robot +++ b/tests/resources/session_keywords.robot @@ -166,4 +166,4 @@ DELETE On Session Get Random ID [Documentation] Generate a unique random ID for test data (call each time for new ID) ${random_id}= Generate Random String 8 [LETTERS][NUMBERS] - RETURN ${random_id} \ No newline at end of file + RETURN ${random_id} diff --git a/tests/resources/transcript_verification.robot b/tests/resources/transcript_verification.robot index 74195565..51cbe4f3 100644 --- a/tests/resources/transcript_verification.robot +++ b/tests/resources/transcript_verification.robot @@ -254,8 +254,8 @@ Verify Segments Match Expected Timestamps Log All ${actual_count} segments matched expected timestamps within ${tolerance}s tolerance INFO - - + + Verify Transcript Content [Documentation] Verify transcript contains expected content and quality [Arguments] ${conversation} ${expected_keywords} ${min_length}=50 @@ -282,4 +282,3 @@ Verify Transcript Content Should Be True ${segment_count} > 0 No segments found Log Transcript verification passed: ${transcript_length} chars, ${segment_count} segments INFO - diff --git a/tests/resources/user_keywords.robot b/tests/resources/user_keywords.robot index b2ef9806..8a46c6a4 100644 --- a/tests/resources/user_keywords.robot +++ b/tests/resources/user_keywords.robot @@ -81,5 +81,3 @@ Update User ${response}= PUT On Session ${session} /api/users/${user_id} json=${updates} expected_status=200 RETURN ${response.json()} - - diff --git a/tests/resources/websocket_keywords.robot b/tests/resources/websocket_keywords.robot index 2eb9381b..58014de5 100644 --- a/tests/resources/websocket_keywords.robot +++ b/tests/resources/websocket_keywords.robot @@ -158,6 +158,12 @@ Close Audio Stream Log Stopped stream ${stream_id}, total chunks: ${total_chunks} RETURN ${total_chunks} +Send Button Event To Stream + [Documentation] Send a button event (SINGLE_PRESS, DOUBLE_PRESS) to an open stream + [Arguments] ${stream_id} ${button_state}=SINGLE_PRESS + Send Button Event ${stream_id} ${button_state} + Log Sent button event ${button_state} to stream ${stream_id} + Close Audio Stream Without Stop Event [Documentation] Close WebSocket connection without sending audio-stop event. ... This simulates abrupt disconnection (network failure, client crash) diff --git a/tests/run-no-api-tests.sh b/tests/run-no-api-tests.sh deleted file mode 100755 index b5c5a505..00000000 --- a/tests/run-no-api-tests.sh +++ /dev/null @@ -1,176 +0,0 @@ -#!/bin/bash - -# Robot Framework Test Runner (No API Keys Required) -# Runs tests that don't require external API services (Deepgram, OpenAI) -# Excludes tests tagged with 'requires-api-keys' - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -print_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Check if we're in the right directory -if [ ! -f "Makefile" ] || [ ! -d "endpoints" ]; then - print_error "Please run this script from the tests/ directory" - exit 1 -fi - -# Set absolute paths for consistent directory references -TESTS_DIR="$(pwd)" -BACKEND_DIR="$(cd ../backends/advanced && pwd)" - -print_info "Robot Framework Test Runner (No API Keys)" -print_info "==========================================" -print_info "This runner executes tests that don't require external API services" -print_info "Tests tagged with 'requires-api-keys', 'slow', and 'sdk' are excluded" - -# Configuration -CLEANUP_CONTAINERS="${CLEANUP_CONTAINERS:-false}" -OUTPUTDIR="${OUTPUTDIR:-results-no-api}" - -# Use mock services config (no API keys needed) -# Set TEST_CONFIG_FILE to point to mock-services.yml inside the container -export TEST_CONFIG_FILE="/app/test-configs/mock-services.yml" - -print_info "Using config file: ${TEST_CONFIG_FILE}" -print_warning "Memory extraction and transcription are disabled in this mode" - -# Load environment variables if available (but don't require them) -if [ -f "setup/.env.test" ]; then - print_info "Loading environment variables from setup/.env.test..." - set -a - source setup/.env.test - set +a -fi - -# Create test environment file if it doesn't exist (without API keys) -if [ ! -f "setup/.env.test" ]; then - print_info "Creating test environment file..." - mkdir -p setup - - cat > setup/.env.test << EOF -# API URLs -API_URL=http://localhost:8001 -BACKEND_URL=http://localhost:8001 -FRONTEND_URL=http://localhost:3001 - -# Test Admin Credentials -ADMIN_EMAIL=test-admin@example.com -ADMIN_PASSWORD=test-admin-password-123 - -# Test Configuration -TEST_TIMEOUT=120 -TEST_DEVICE_NAME=robot-test - -# Note: No API keys required for this test mode -# OPENAI_API_KEY and DEEPGRAM_API_KEY are not needed -EOF - print_success "Created setup/.env.test" -fi - -# Start test containers using dedicated startup script -FRESH_BUILD=true "$TESTS_DIR/setup-test-containers.sh" - -# Run Robot Framework tests via Makefile with tag exclusion -# Exclude tests that require API keys, slow tests, and SDK tests -print_info "Running Robot Framework tests (excluding requires-api-keys, slow, sdk tags)..." -print_info "Output directory: $OUTPUTDIR" - -# Run tests with tag exclusion -if timeout 30m uv run --with-requirements test-requirements.txt \ - robot --exclude requires-api-keys \ - --exclude slow \ - --exclude sdk \ - --outputdir "$OUTPUTDIR" \ - --loglevel INFO \ - --consolecolors on \ - --consolemarkers on \ - .; then - TEST_EXIT_CODE=0 -else - TEST_EXIT_CODE=$? -fi - -# Show service logs if tests failed -if [ $TEST_EXIT_CODE -ne 0 ]; then - print_info "Showing service logs..." - cd "$BACKEND_DIR" - echo "=== Backend Logs (last 50 lines) ===" - docker compose -f docker-compose-test.yml logs --tail=50 chronicle-backend-test - echo "" - echo "=== Worker Logs (last 50 lines) ===" - docker compose -f docker-compose-test.yml logs --tail=50 workers-test - cd "$TESTS_DIR" -fi - -# Display test results summary -if [ -f "$OUTPUTDIR/output.xml" ]; then - print_info "Test Results Summary:" - uv run python3 << 'PYTHON_SCRIPT' -import xml.etree.ElementTree as ET -import os - -output_file = os.getenv('OUTPUTDIR', 'results-no-api') + '/output.xml' -try: - tree = ET.parse(output_file) - root = tree.getroot() - - # Get overall stats - stats = root.find('.//total/stat') - if stats is not None: - passed = stats.get("pass", "0") - failed = stats.get("fail", "0") - print(f'✅ Passed: {passed}') - print(f'❌ Failed: {failed}') - print(f'📊 Total: {int(passed) + int(failed)}') - - # Show failed tests if any - if int(failed) > 0: - print('\n❌ Failed Tests:') - failed_tests = root.findall('.//test') - for test in failed_tests: - status = test.find('status') - if status is not None and status.get('status') == 'FAIL': - test_name = test.get('name', 'Unknown') - print(f' - {test_name}') -except Exception as e: - print(f'Error parsing results: {e}') -PYTHON_SCRIPT -fi - -# Cleanup containers if requested -if [ "$CLEANUP_CONTAINERS" = "true" ]; then - print_info "Cleaning up test containers..." - cd "$BACKEND_DIR" - docker compose -f docker-compose-test.yml down -v --remove-orphans - cd "$TESTS_DIR" - print_success "Cleanup completed" -fi - -# Final status -if [ $TEST_EXIT_CODE -eq 0 ]; then - print_success "All tests passed! ✅" -else - print_error "Some tests failed ❌" - exit $TEST_EXIT_CODE -fi diff --git a/tests/run-robot-tests.sh b/tests/run-robot-tests.sh index 7f7cd5c4..190ee2d2 100755 --- a/tests/run-robot-tests.sh +++ b/tests/run-robot-tests.sh @@ -237,7 +237,7 @@ for i, worker in enumerate(workers, 1): # Capture logs from all services print_info "Capturing service logs..." -SERVICES=(chronicle-backend-test workers-test mongo-test redis-test qdrant-test speaker-service-test) +SERVICES=(chronicle-backend-test workers-test mongo-test redis-test speaker-service-test) for service in "${SERVICES[@]}"; do docker compose -f docker-compose-test.yml logs --tail=200 "$service" > "$LOG_DIR/${service}.log" 2>&1 || true done diff --git a/tests/scripts/mock_transcription_server.py b/tests/scripts/mock_transcription_server.py index d4d25e6a..a7ed6505 100755 --- a/tests/scripts/mock_transcription_server.py +++ b/tests/scripts/mock_transcription_server.py @@ -8,6 +8,7 @@ Usage: python mock_transcription_server.py [--port PORT] [--host HOST] """ +import argparse import asyncio import json import logging @@ -15,14 +16,15 @@ try: import websockets - from websockets.server import serve, WebSocketServerProtocol + from websockets.server import WebSocketServerProtocol, serve except ImportError: - print("ERROR: websockets package not found. Install with: uv pip install websockets") + print( + "ERROR: websockets package not found. Install with: uv pip install websockets" + ) exit(1) logging.basicConfig( - level=logging.INFO, - format='%(asctime)s [%(levelname)s] %(message)s' + level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" ) logger = logging.getLogger(__name__) @@ -42,13 +44,17 @@ async def handle_connection(self, websocket: WebSocketServerProtocol): try: # Send initial connection acknowledgment (simplified format for mock config) - await websocket.send(json.dumps({ - "type": "Results", - "is_final": False, - "text": "", - "words": [], - "segments": [] - })) + await websocket.send( + json.dumps( + { + "type": "Results", + "is_final": False, + "text": "", + "words": [], + "segments": [], + } + ) + ) chunk_count = 0 duration = 0.0 @@ -61,39 +67,102 @@ async def handle_connection(self, websocket: WebSocketServerProtocol): # Every 10 chunks, send an interim result (simplified format) if chunk_count % 10 == 0: - await websocket.send(json.dumps({ - "type": "Results", - "is_final": False, - "text": f"This is mock transcription chunk {chunk_count // 10}", - "confidence": 0.95, - "words": [ - {"word": "This", "start": 0.0, "end": 0.1, "confidence": 0.95}, - {"word": "is", "start": 0.1, "end": 0.2, "confidence": 0.95}, - {"word": "mock", "start": 0.2, "end": 0.3, "confidence": 0.95}, - {"word": "transcription", "start": 0.3, "end": 0.5, "confidence": 0.95}, - ], - "segments": [] - })) + await websocket.send( + json.dumps( + { + "type": "Results", + "is_final": False, + "text": f"This is mock transcription chunk {chunk_count // 10}", + "confidence": 0.95, + "words": [ + { + "word": "This", + "start": 0.0, + "end": 0.1, + "confidence": 0.95, + }, + { + "word": "is", + "start": 0.1, + "end": 0.2, + "confidence": 0.95, + }, + { + "word": "mock", + "start": 0.2, + "end": 0.3, + "confidence": 0.95, + }, + { + "word": "transcription", + "start": 0.3, + "end": 0.5, + "confidence": 0.95, + }, + ], + "segments": [], + } + ) + ) # Every 50 chunks, send a final result (simplified format) # Make sure words span at least 2 seconds for speech detection if chunk_count % 50 == 0: - await websocket.send(json.dumps({ - "type": "Results", - "is_final": True, - "text": "This is a final mock transcription segment.", - "confidence": 0.98, - "words": [ - {"word": "This", "start": 0.0, "end": 0.3, "confidence": 0.98}, - {"word": "is", "start": 0.3, "end": 0.6, "confidence": 0.98}, - {"word": "a", "start": 0.6, "end": 0.8, "confidence": 0.98}, - {"word": "final", "start": 0.8, "end": 1.2, "confidence": 0.98}, - {"word": "mock", "start": 1.2, "end": 1.5, "confidence": 0.98}, - {"word": "transcription", "start": 1.5, "end": 2.0, "confidence": 0.98}, - {"word": "segment", "start": 2.0, "end": 2.5, "confidence": 0.98}, - ], - "segments": [] - })) + await websocket.send( + json.dumps( + { + "type": "Results", + "is_final": True, + "text": "This is a final mock transcription segment.", + "confidence": 0.98, + "words": [ + { + "word": "This", + "start": 0.0, + "end": 0.3, + "confidence": 0.98, + }, + { + "word": "is", + "start": 0.3, + "end": 0.6, + "confidence": 0.98, + }, + { + "word": "a", + "start": 0.6, + "end": 0.8, + "confidence": 0.98, + }, + { + "word": "final", + "start": 0.8, + "end": 1.2, + "confidence": 0.98, + }, + { + "word": "mock", + "start": 1.2, + "end": 1.5, + "confidence": 0.98, + }, + { + "word": "transcription", + "start": 1.5, + "end": 2.0, + "confidence": 0.98, + }, + { + "word": "segment", + "start": 2.0, + "end": 2.5, + "confidence": 0.98, + }, + ], + "segments": [], + } + ) + ) elif isinstance(message, str): # Control message received @@ -102,22 +171,61 @@ async def handle_connection(self, websocket: WebSocketServerProtocol): if msg.get("type") == "CloseStream": logger.info(f"📴 Received CloseStream from {client_id}") # Send final results (simplified format) with words spanning >2s - await websocket.send(json.dumps({ - "type": "Results", - "is_final": True, - "text": "This is the complete mock transcription result.", - "confidence": 0.98, - "words": [ - {"word": "This", "start": 0.0, "end": 0.4, "confidence": 0.98}, - {"word": "is", "start": 0.4, "end": 0.7, "confidence": 0.98}, - {"word": "the", "start": 0.7, "end": 1.0, "confidence": 0.98}, - {"word": "complete", "start": 1.0, "end": 1.5, "confidence": 0.98}, - {"word": "mock", "start": 1.5, "end": 1.9, "confidence": 0.98}, - {"word": "transcription", "start": 1.9, "end": 2.5, "confidence": 0.98}, - {"word": "result", "start": 2.5, "end": 3.0, "confidence": 0.98}, - ], - "segments": [] - })) + await websocket.send( + json.dumps( + { + "type": "Results", + "is_final": True, + "text": "This is the complete mock transcription result.", + "confidence": 0.98, + "words": [ + { + "word": "This", + "start": 0.0, + "end": 0.4, + "confidence": 0.98, + }, + { + "word": "is", + "start": 0.4, + "end": 0.7, + "confidence": 0.98, + }, + { + "word": "the", + "start": 0.7, + "end": 1.0, + "confidence": 0.98, + }, + { + "word": "complete", + "start": 1.0, + "end": 1.5, + "confidence": 0.98, + }, + { + "word": "mock", + "start": 1.5, + "end": 1.9, + "confidence": 0.98, + }, + { + "word": "transcription", + "start": 1.9, + "end": 2.5, + "confidence": 0.98, + }, + { + "word": "result", + "start": 2.5, + "end": 3.0, + "confidence": 0.98, + }, + ], + "segments": [], + } + ) + ) break except json.JSONDecodeError: logger.warning(f"⚠️ Invalid JSON from {client_id}: {message}") @@ -127,19 +235,19 @@ async def handle_connection(self, websocket: WebSocketServerProtocol): except Exception as e: logger.error(f"❌ Error handling connection from {client_id}: {e}") finally: - logger.info(f"✅ Cleaned up connection from {client_id} (received {chunk_count} chunks)") + logger.info( + f"✅ Cleaned up connection from {client_id} (received {chunk_count} chunks)" + ) async def start(self): """Start the WebSocket server.""" logger.info(f"🚀 Starting Mock Transcription Server on {self.host}:{self.port}") - self.server = await serve( - self.handle_connection, - self.host, - self.port - ) + self.server = await serve(self.handle_connection, self.host, self.port) - logger.info(f"✅ Mock Transcription Server ready at ws://{self.host}:{self.port}") + logger.info( + f"✅ Mock Transcription Server ready at ws://{self.host}:{self.port}" + ) async def run_forever(self): """Start the server and run until interrupted.""" @@ -158,11 +266,13 @@ async def stop(self): async def main(): """Main entry point.""" - import argparse - parser = argparse.ArgumentParser(description="Mock WebSocket Transcription Server") - parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") - parser.add_argument("--port", type=int, default=9999, help="Port to bind to (default: 9999)") + parser.add_argument( + "--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)" + ) + parser.add_argument( + "--port", type=int, default=9999, help="Port to bind to (default: 9999)" + ) args = parser.parse_args() server = MockTranscriptionServer(host=args.host, port=args.port) diff --git a/tests/scripts/verify_mock_servers.py b/tests/scripts/verify_mock_servers.py index e40aae4e..8983e125 100755 --- a/tests/scripts/verify_mock_servers.py +++ b/tests/scripts/verify_mock_servers.py @@ -14,6 +14,7 @@ import json import sys import urllib.request + import websockets @@ -21,7 +22,7 @@ def test_llm_models(): """Test mock LLM models endpoint.""" print("Testing Mock LLM - Models endpoint...") try: - with urllib.request.urlopen('http://localhost:11435/v1/models') as response: + with urllib.request.urlopen("http://localhost:11435/v1/models") as response: data = json.loads(response.read()) assert "data" in data assert len(data["data"]) == 2 @@ -43,14 +44,14 @@ def test_llm_fact_extraction(): "model": "gpt-4o-mini", "messages": [ {"role": "system", "content": "FACT_RETRIEVAL_PROMPT extract facts"}, - {"role": "user", "content": "I like hiking"} - ] + {"role": "user", "content": "I like hiking"}, + ], } req = urllib.request.Request( - 'http://localhost:11435/v1/chat/completions', + "http://localhost:11435/v1/chat/completions", data=json.dumps(request_data).encode(), - headers={'Content-Type': 'application/json'} + headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req) as response: @@ -74,14 +75,14 @@ def test_llm_memory_update(): "model": "gpt-4o-mini", "messages": [ {"role": "system", "content": "UPDATE_MEMORY_PROMPT memory manager"}, - {"role": "user", "content": "User now likes apple pie"} - ] + {"role": "user", "content": "User now likes apple pie"}, + ], } req = urllib.request.Request( - 'http://localhost:11435/v1/chat/completions', + "http://localhost:11435/v1/chat/completions", data=json.dumps(request_data).encode(), - headers={'Content-Type': 'application/json'} + headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req) as response: @@ -103,13 +104,13 @@ def test_llm_embeddings(): try: request_data = { "model": "text-embedding-3-small", - "input": ["test text 1", "test text 2"] + "input": ["test text 1", "test text 2"], } req = urllib.request.Request( - 'http://localhost:11435/v1/embeddings', + "http://localhost:11435/v1/embeddings", data=json.dumps(request_data).encode(), - headers={'Content-Type': 'application/json'} + headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req) as response: @@ -178,9 +179,9 @@ async def test_streaming_stt(): def main(): """Run all verification tests.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("Mock Servers Verification") - print("="*60 + "\n") + print("=" * 60 + "\n") results = [] @@ -194,11 +195,11 @@ def main(): results.append(asyncio.run(test_streaming_stt())) # Summary - print("\n" + "="*60) + print("\n" + "=" * 60) passed = sum(results) total = len(results) print(f"Results: {passed}/{total} tests passed") - print("="*60 + "\n") + print("=" * 60 + "\n") if passed == total: print("✅ All mock servers are working correctly!") diff --git a/tests/setup-test-containers.sh b/tests/setup-test-containers.sh index 8ff3ff45..745c3ce2 100755 --- a/tests/setup-test-containers.sh +++ b/tests/setup-test-containers.sh @@ -79,4 +79,3 @@ print_success "All services ready!" print_info "Backend: http://localhost:8001" print_info "MongoDB: localhost:27018" print_info "Redis: localhost:6380" -print_info "Qdrant: localhost:6337" diff --git a/tests/setup/README.md b/tests/setup/README.md index 3e9ba561..99fff19c 100644 --- a/tests/setup/README.md +++ b/tests/setup/README.md @@ -23,7 +23,7 @@ Setup keywords with dev/prod modes: - **Dev Mode Setup** (default): Reuse containers, clear data only (~5s) - **Dev Mode Setup With Rebuild**: Rebuild containers in dev mode (~60s) - **Prod Mode Setup**: Complete teardown and rebuild for CI/CD (~90s) -- **Clear Test Databases**: Clear MongoDB, Qdrant, Redis, and audio files +- **Clear Test Databases**: Clear MongoDB, FalkorDB, Redis, and audio files - **Readiness Check**: Verify service availability - **Health Check**: Verify service health @@ -105,7 +105,7 @@ robot tests/ ``` - **Default behavior** - no environment variable needed - Reuses existing containers if available -- Only clears test data (MongoDB, Qdrant, Redis, audio files) +- Only clears test data (MongoDB, FalkorDB, Redis, audio files) - Keeps containers running after tests - **Fastest** for rapid iteration (~5s, instant if containers up) - Best for: local development, rapid testing @@ -233,6 +233,6 @@ FRESH_RUN=true robot tests/ ## Related Documentation -- **[@CLAUDE.md](../../CLAUDE.md)**: Project overview and development guide +- **[@AGENTS.md](../../AGENTS.md)**: Project overview and development guide - **[@tests/README.md](../README.md)**: Testing overview (if exists) - **[@backends/advanced/README.md](../../backends/advanced/README.md)**: Backend service documentation diff --git a/tests/setup/init.py b/tests/setup/init.py index 8223e619..61e25d28 100644 --- a/tests/setup/init.py +++ b/tests/setup/init.py @@ -50,9 +50,7 @@ def main(): # Ensure template exists if not ENV_TEST_TEMPLATE.exists(): - console.print( - f"[red][ERROR][/red] Template not found: {ENV_TEST_TEMPLATE}" - ) + console.print(f"[red][ERROR][/red] Template not found: {ENV_TEST_TEMPLATE}") sys.exit(1) # Copy template to .env.test if it doesn't exist diff --git a/tests/setup/setup_keywords.robot b/tests/setup/setup_keywords.robot index b0cb87b7..a082c985 100644 --- a/tests/setup/setup_keywords.robot +++ b/tests/setup/setup_keywords.robot @@ -85,7 +85,7 @@ Prod Mode Setup Log To Console Tearing down existing containers and volumes... Stop Docker Services remove_volumes=${True} - Run Process rm -rf data/test_mongo_data data/test_qdrant_data data/test_audio_chunks cwd=${BACKEND_DIR} shell=True + Run Process rm -rf data/test_mongo_data data/test_audio_chunks cwd=${BACKEND_DIR} shell=True Log To Console Building and starting fresh containers... Start Docker Services build=${True} @@ -109,7 +109,7 @@ Start Docker Services # Clean up any stopped/stuck containers first Run Process docker compose -f ${compose_file} down -v cwd=${working_dir} shell=True - Run Process docker rm -f ${MONGO_CONTAINER} ${REDIS_CONTAINER} ${QDRANT_CONTAINER} ${BACKEND_CONTAINER} ${WORKERS_CONTAINER} ${WEBUI_CONTAINER} shell=True + Run Process docker rm -f ${MONGO_CONTAINER} ${REDIS_CONTAINER} ${BACKEND_CONTAINER} ${WORKERS_CONTAINER} ${WEBUI_CONTAINER} shell=True # Start containers with HF_TOKEN passed through if available IF ${build} @@ -210,4 +210,3 @@ Check Environment Variables END END RETURN ${missing_vars} - diff --git a/tests/setup/teardown_keywords.robot b/tests/setup/teardown_keywords.robot index cd4b2b5a..3bdfb7fc 100644 --- a/tests/setup/teardown_keywords.robot +++ b/tests/setup/teardown_keywords.robot @@ -53,7 +53,6 @@ Prod Mode Teardown # Clean up any remaining volumes Run Process rm -rf ${BACKEND_DIR}/data/test_mongo_data shell=True - Run Process rm -rf ${BACKEND_DIR}/data/test_qdrant_data shell=True Run Process rm -rf ${BACKEND_DIR}/data/test_audio_chunks shell=True # Delete all HTTP sessions @@ -136,4 +135,3 @@ Prod Mode Teardown # ELSE # Log To Console Skipping speaker recognition cleanup (dev mode) # END - diff --git a/tests/setup/test_data.py b/tests/setup/test_data.py index e1821796..61776b48 100644 --- a/tests/setup/test_data.py +++ b/tests/setup/test_data.py @@ -1,6 +1,7 @@ """ Test data for Robot Framework tests """ + import os # Test Data @@ -8,29 +9,25 @@ { "id": "conv_001", "transcript": "This is a test conversation about AI development.", - "created_at": "2025-01-15T10:00:00Z" + "created_at": "2025-01-15T10:00:00Z", }, { "id": "conv_002", "transcript": "Another test conversation discussing machine learning.", - "created_at": "2025-01-15T11:00:00Z" - } + "created_at": "2025-01-15T11:00:00Z", + }, ] SAMPLE_MEMORIES = [ - { - "text": "User prefers AI discussions in the morning", - "importance": 0.8 - }, - { - "text": "User is interested in machine learning applications", - "importance": 0.7 - } + {"text": "User prefers AI discussions in the morning", "importance": 0.8}, + {"text": "User is interested in machine learning applications", "importance": 0.7}, ] # Construct path relative to the tests directory (works from any working directory) _tests_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -TEST_AUDIO_FILE = os.path.join(_tests_dir, "test_assets", "DIY_Experts_Glass_Blowing_16khz_mono_1min.wav") +TEST_AUDIO_FILE = os.path.join( + _tests_dir, "test_assets", "DIY_Experts_Glass_Blowing_16khz_mono_1min.wav" +) TEST_DEVICE_NAME = "Robot-test-device" # Expected content for transcript quality verification @@ -47,7 +44,7 @@ "Class involves making a trumpet flower", "Gravity is used as a tool in glass blowing", "Nick did most of the turning during the demonstration", - "The video is sponsored by Squarespace." + "The video is sponsored by Squarespace.", ] # Expected segment timestamps for DIY Glass Blowing audio (4-minute version, 500 chunks) diff --git a/tests/setup/test_env.py b/tests/setup/test_env.py index 924d0592..f5c86129 100644 --- a/tests/setup/test_env.py +++ b/tests/setup/test_env.py @@ -1,6 +1,7 @@ # Test Environment Configuration import os from pathlib import Path + from dotenv import load_dotenv # Load environment files with correct precedence: @@ -30,34 +31,29 @@ # Final precedence: environment variables > .env.test > .env # API Configuration -API_URL = 'http://localhost:8001' # Use BACKEND_URL from test.env -API_BASE = 'http://localhost:8001/api' -SPEAKER_RECOGNITION_URL = 'http://localhost:8085' # Speaker recognition service +API_URL = "http://localhost:8001" # Use BACKEND_URL from test.env +API_BASE = "http://localhost:8001/api" +SPEAKER_RECOGNITION_URL = "http://localhost:8085" # Speaker recognition service -WEB_URL = os.getenv('FRONTEND_URL', 'http://localhost:3001') # Use FRONTEND_URL from test.env +WEB_URL = os.getenv( + "FRONTEND_URL", "http://localhost:3001" +) # Use FRONTEND_URL from test.env # Test-specific credentials (override any values from .env) # These are the credentials used in docker-compose-test.yml -ADMIN_EMAIL = 'test-admin@example.com' -ADMIN_PASSWORD = 'test-admin-password-123' +ADMIN_EMAIL = "test-admin@example.com" +ADMIN_PASSWORD = "test-admin-password-123" # Admin user credentials (Robot Framework format) -ADMIN_USER = { - "email": ADMIN_EMAIL, - "password": ADMIN_PASSWORD -} +ADMIN_USER = {"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD} -TEST_USER = { - "email": "test@example.com", - "password": "test-password" -} +TEST_USER = {"email": "test@example.com", "password": "test-password"} # Individual variables for Robot Framework TEST_USER_EMAIL = "test@example.com" TEST_USER_PASSWORD = "test-password" - # API Endpoints ENDPOINTS = { "health": "/health", @@ -66,25 +62,31 @@ "conversations": "/api/conversations", "memories": "/api/memories", "memory_search": "/api/memories/search", - "users": "/api/users" + "users": "/api/users", } # API Keys (loaded from test.env) -OPENAI_API_KEY = os.getenv('OPENAI_API_KEY') -DEEPGRAM_API_KEY = os.getenv('DEEPGRAM_API_KEY') -HF_TOKEN = os.getenv('HF_TOKEN') +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY") +HF_TOKEN = os.getenv("HF_TOKEN") # Test Configuration -TEST_CONFIG = { - "retry_count": 3, - "retry_delay": 1, - "default_timeout": 30 -} +TEST_CONFIG = {"retry_count": 3, "retry_delay": 1, "default_timeout": 30} + +# Container engine: docker in CI; export CONTAINER_ENGINE=podman on podman machines. +CONTAINER_ENGINE = os.getenv("CONTAINER_ENGINE", "docker") + +# Container names (docker-compose-test.yml project name: backend-test). +# docker compose joins with "-", podman-compose with "_". +_SEP = "_" if CONTAINER_ENGINE == "podman" else "-" + + +def _container_name(service: str) -> str: + return f"backend-test{_SEP}{service}{_SEP}1" + -# Docker Container Names (based on docker-compose-test.yml project name: backend-test) -BACKEND_CONTAINER = "backend-test-chronicle-backend-test-1" -WORKERS_CONTAINER = "backend-test-workers-test-1" -MONGO_CONTAINER = "backend-test-mongo-test-1" -REDIS_CONTAINER = "backend-test-redis-test-1" -QDRANT_CONTAINER = "backend-test-qdrant-test-1" -WEBUI_CONTAINER = "backend-test-webui-test-1" +BACKEND_CONTAINER = _container_name("chronicle-backend-test") +WORKERS_CONTAINER = _container_name("workers-test") +MONGO_CONTAINER = _container_name("mongo-test") +REDIS_CONTAINER = _container_name("redis-test") +WEBUI_CONTAINER = _container_name("webui-test") diff --git a/tests/setup/test_manager_keywords.robot b/tests/setup/test_manager_keywords.robot index ae2a0afa..af974ba7 100644 --- a/tests/setup/test_manager_keywords.robot +++ b/tests/setup/test_manager_keywords.robot @@ -63,12 +63,6 @@ Clear Test Databases # Clear admin user's registered_clients dict to prevent client_id counter increments Run Process docker exec ${MONGO_CONTAINER} mongosh test_db --eval "db.users.updateOne({'email':'${ADMIN_EMAIL}'}, {\\$set: {'registered_clients': {}}})" shell=True - # Clear Qdrant collections - # Note: Fixture memories will be lost here unless we implement Qdrant metadata filtering - Run Process curl -s -X DELETE http://localhost:6337/collections/memories shell=True - Run Process curl -s -X DELETE http://localhost:6337/collections/conversations shell=True - Log To Console Qdrant collections cleared - # Clear audio files (except fixtures subfolder) Run Process bash -c find ${BACKEND_DIR}/data/test_audio_chunks -maxdepth 1 -name "*.wav" -delete || true shell=True # Don't delete plugin database - just clear its contents later via Clear Plugin Events keyword @@ -96,10 +90,6 @@ Clear All Test Data Run Process docker exec ${MONGO_CONTAINER} mongosh test_db --eval "db.audio_chunks.deleteMany({})" shell=True Log To Console MongoDB completely cleared - # Clear Qdrant - Run Process curl -s -X DELETE http://localhost:6337/collections/memories shell=True - Run Process curl -s -X DELETE http://localhost:6337/collections/conversations shell=True - # Clear all audio files Run Process bash -c rm -rf ${BACKEND_DIR}/data/test_audio_chunks/* || true shell=True Run Process bash -c rm -rf ${BACKEND_DIR}/data/test_debug_dir/* || true shell=True diff --git a/tests/show_results.py b/tests/show_results.py index 0d2e1b2c..991ec1a7 100755 --- a/tests/show_results.py +++ b/tests/show_results.py @@ -8,9 +8,9 @@ import argparse import sys +from datetime import datetime from pathlib import Path from xml.etree import ElementTree as ET -from datetime import datetime # ANSI color codes @@ -132,12 +132,14 @@ def parse_results(xml_file): except (ValueError, TypeError): pass - failed_tests.append({ - "name": test.get("name"), - "suite": " > ".join(suite_path) if suite_path else "Unknown Suite", - "error": error_msg.strip(), - "duration": test_duration - }) + failed_tests.append( + { + "name": test.get("name"), + "suite": " > ".join(suite_path) if suite_path else "Unknown Suite", + "error": error_msg.strip(), + "duration": test_duration, + } + ) return { "passed": passed, @@ -145,7 +147,7 @@ def parse_results(xml_file): "skipped": skipped, "total": total, "duration_seconds": duration_seconds, - "failed_tests": failed_tests + "failed_tests": failed_tests, } @@ -187,32 +189,46 @@ def print_summary(results, xml_file, show_detailed=False): # File info file_mtime = datetime.fromtimestamp(xml_file.stat().st_mtime) - print(f"Results from: {colorize(str(xml_file.relative_to(Path.cwd())), Colors.BLUE)}") + print( + f"Results from: {colorize(str(xml_file.relative_to(Path.cwd())), Colors.BLUE)}" + ) print(f"Last modified: {file_mtime.strftime('%Y-%m-%d %H:%M:%S')}") print(f"Total duration: {format_duration(results['duration_seconds'])}") print() # Statistics print(colorize("Test Statistics:", Colors.BOLD)) - print(f" {colorize('✓', Colors.GREEN)} Passed: {colorize(str(results['passed']), Colors.GREEN)}") - print(f" {colorize('✗', Colors.RED)} Failed: {colorize(str(results['failed']), Colors.RED)}") - if results['skipped'] > 0: - print(f" {colorize('○', Colors.YELLOW)} Skipped: {colorize(str(results['skipped']), Colors.YELLOW)}") + print( + f" {colorize('✓', Colors.GREEN)} Passed: {colorize(str(results['passed']), Colors.GREEN)}" + ) + print( + f" {colorize('✗', Colors.RED)} Failed: {colorize(str(results['failed']), Colors.RED)}" + ) + if results["skipped"] > 0: + print( + f" {colorize('○', Colors.YELLOW)} Skipped: {colorize(str(results['skipped']), Colors.YELLOW)}" + ) print(f" Total: {results['total']}") - if results['total'] > 0: - pass_rate = (results['passed'] / results['total']) * 100 - color = Colors.GREEN if pass_rate == 100 else Colors.YELLOW if pass_rate >= 90 else Colors.RED + if results["total"] > 0: + pass_rate = (results["passed"] / results["total"]) * 100 + color = ( + Colors.GREEN + if pass_rate == 100 + else Colors.YELLOW if pass_rate >= 90 else Colors.RED + ) print(f" Pass rate: {colorize(f'{pass_rate:.1f}%', color)}") print() # Failed tests - if results['failed'] > 0: + if results["failed"] > 0: print(colorize(f"Failed Tests ({results['failed']}):", Colors.BOLD)) print() - for i, test in enumerate(results['failed_tests'], 1): - print(f"{colorize(f'{i}.', Colors.RED)} {colorize(test['name'], Colors.BOLD)}") + for i, test in enumerate(results["failed_tests"], 1): + print( + f"{colorize(f'{i}.', Colors.RED)} {colorize(test['name'], Colors.BOLD)}" + ) print(f" Suite: {test['suite']}") if show_detailed: @@ -221,13 +237,15 @@ def print_summary(results, xml_file, show_detailed=False): print(f" Duration: {format_duration(test['duration'])}") else: # Truncate error in summary mode - error_lines = test['error'].split('\n') + error_lines = test["error"].split("\n") first_line = error_lines[0] if len(first_line) > 100: first_line = first_line[:97] + "..." print(f" Error: {first_line}") if len(error_lines) > 1: - print(f" {colorize('(Use --detailed for full error)', Colors.YELLOW)}") + print( + f" {colorize('(Use --detailed for full error)', Colors.YELLOW)}" + ) print() else: print(colorize("🎉 All tests passed!", Colors.GREEN)) @@ -273,24 +291,24 @@ def main(): %(prog)s --path # Print path to HTML report %(prog)s --detailed # Detailed terminal output with full errors %(prog)s --dir results-no-api # Specific result directory - """ + """, ) parser.add_argument( "--dir", - help="Specific result directory to use (default: auto-find most recent)" + help="Specific result directory to use (default: auto-find most recent)", ) parser.add_argument( "--path", action="store_true", - help="Print path to HTML report instead of showing summary" + help="Print path to HTML report instead of showing summary", ) parser.add_argument( "--detailed", action="store_true", - help="Show detailed output with full error messages" + help="Show detailed output with full error messages", ) args = parser.parse_args() @@ -300,7 +318,12 @@ def main(): if xml_file is None: if args.dir: - print(colorize(f"Error: No test results found in '{args.dir}' directory", Colors.RED)) + print( + colorize( + f"Error: No test results found in '{args.dir}' directory", + Colors.RED, + ) + ) else: print(colorize("Error: No test results found", Colors.RED)) print() @@ -331,7 +354,7 @@ def main(): print_summary(results, xml_file, show_detailed=args.detailed) # Return exit code based on test results - return 1 if results['failed'] > 0 else 0 + return 1 if results["failed"] > 0 else 0 if __name__ == "__main__": diff --git a/tests/test-requirements.txt b/tests/test-requirements.txt index f32614e0..2ea12f65 100644 --- a/tests/test-requirements.txt +++ b/tests/test-requirements.txt @@ -6,4 +6,5 @@ robotframework-databaselibrary python-dotenv websockets pymongo - \ No newline at end of file +omegaconf +pyyaml diff --git a/tests/unit/test_config_loading.py b/tests/unit/test_config_loading.py index 28477462..abd51682 100644 --- a/tests/unit/test_config_loading.py +++ b/tests/unit/test_config_loading.py @@ -1,199 +1,98 @@ -"""Test configuration loading and merging. +"""Tests for the OmegaConf-backed configuration loader.""" -Tests for the configuration system that merges defaults.yml with config.yml -and provides proper caching and reload mechanisms. -""" - -import pytest from pathlib import Path -from advanced_omi_backend.config import get_config, merge_configs, reload_config - -def test_merge_configs_basic(): - """Test basic config merging.""" - defaults = {"a": 1, "b": 2} - overrides = {"b": 3, "c": 4} +import pytest +from omegaconf import OmegaConf - result = merge_configs(defaults, overrides) +from advanced_omi_backend.config import get_config, reload_config - assert result["a"] == 1 # From defaults - assert result["b"] == 3 # Override - assert result["c"] == 4 # New key +@pytest.fixture +def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("CONFIG_DIR", str(tmp_path)) + monkeypatch.delenv("CONFIG_FILE", raising=False) + return tmp_path -def test_merge_configs_nested(): - """Test nested dictionary merging.""" - defaults = { - "memory": { - "provider": "chronicle", - "timeout": 120 - } - } - overrides = { - "memory": { - "provider": "openmemory_mcp" - } - } - result = merge_configs(defaults, overrides) - - assert result["memory"]["provider"] == "openmemory_mcp" # Override - assert result["memory"]["timeout"] == 120 # Preserved from defaults +def test_get_config_merges_defaults_and_user_overrides(config_dir: Path): + OmegaConf.save( + { + "defaults": {"llm": "openai-llm", "stt": "stt-deepgram"}, + "backend": {"cleanup": {"enabled": False, "retention_days": 30}}, + }, + config_dir / "defaults.yml", + ) + OmegaConf.save( + { + "defaults": {"llm": "local-llm"}, + "backend": {"cleanup": {"retention_days": 14}}, + }, + config_dir / "config.yml", + ) + config = get_config(force_reload=True) -def test_merge_configs_deep_nested(): - """Test deeply nested dictionary merging.""" - defaults = { - "models": { - "llm": { - "openai": { - "model": "gpt-4o-mini", - "temperature": 0.2, - "max_tokens": 2000 - } - } - } - } - overrides = { - "models": { - "llm": { - "openai": { - "temperature": 0.5 - } - } - } + assert config["defaults"] == {"llm": "local-llm", "stt": "stt-deepgram"} + assert config["backend"]["cleanup"] == { + "enabled": False, + "retention_days": 14, } - result = merge_configs(defaults, overrides) - - assert result["models"]["llm"]["openai"]["model"] == "gpt-4o-mini" # Preserved - assert result["models"]["llm"]["openai"]["temperature"] == 0.5 # Override - assert result["models"]["llm"]["openai"]["max_tokens"] == 2000 # Preserved - - -def test_merge_configs_list_replacement(): - """Test that lists are replaced, not merged.""" - defaults = {"items": [1, 2, 3]} - overrides = {"items": [4, 5]} - - result = merge_configs(defaults, overrides) - - assert result["items"] == [4, 5] # List replaced entirely - - -def test_merge_configs_empty_override(): - """Test merging with empty override dictionary.""" - defaults = {"a": 1, "b": 2} - overrides = {} - - result = merge_configs(defaults, overrides) - - assert result["a"] == 1 - assert result["b"] == 2 - -def test_merge_configs_empty_defaults(): - """Test merging with empty defaults dictionary.""" - defaults = {} - overrides = {"a": 1, "b": 2} - - result = merge_configs(defaults, overrides) - - assert result["a"] == 1 - assert result["b"] == 2 - - -def test_get_config_structure(): - """Test that get_config returns expected structure.""" - config = get_config() - - # Should have main sections - assert isinstance(config, dict) - assert "defaults" in config or "models" in config # At least one of these should exist - - -def test_get_config_caching(): - """Test config caching mechanism.""" - config1 = get_config() - config2 = get_config() - - # Should return cached instance (same object) - assert config1 is config2 - - -def test_reload_config(): - """Test config reload invalidates cache.""" - config1 = get_config() - config2 = reload_config() - - # Should be different instances after reload - # (Note: Content might be the same, but object should be different) - # We check that reload returns a config object - assert isinstance(config2, dict) - - -def test_merge_configs_none_handling(): - """Test handling of None values in merging.""" - defaults = {"a": 1, "b": None} - overrides = {"b": 2, "c": None} +def test_get_config_merges_models_by_name(config_dir: Path): + OmegaConf.save( + { + "models": [ + {"name": "default-llm", "model_type": "llm"}, + {"name": "default-stt", "model_type": "stt"}, + ] + }, + config_dir / "defaults.yml", + ) + OmegaConf.save( + { + "models": [ + { + "name": "default-llm", + "model_type": "llm", + "model_name": "custom-model", + } + ] + }, + config_dir / "config.yml", + ) - result = merge_configs(defaults, overrides) + config = get_config(force_reload=True) - assert result["a"] == 1 - assert result["b"] == 2 # Override None with value - assert result["c"] is None # New key with None + assert config["models"] == [ + { + "name": "default-llm", + "model_type": "llm", + "model_name": "custom-model", + }, + {"name": "default-stt", "model_type": "stt"}, + ] -def test_merge_configs_complex_scenario(): - """Test complex real-world scenario with mixed types.""" - defaults = { - "defaults": { - "llm": "openai-llm", - "stt": "stt-deepgram" - }, - "models": [ - {"name": "model1", "type": "llm"}, - {"name": "model2", "type": "embedding"} - ], - "memory": { - "provider": "chronicle", - "timeout_seconds": 1200, - "extraction": { - "enabled": True, - "prompt": "Default prompt" - } - } - } - overrides = { - "defaults": { - "llm": "local-llm" - }, - "models": [ - {"name": "model3", "type": "llm"} - ], - "memory": { - "extraction": { - "prompt": "Custom prompt" - } - } - } +def test_reload_config_reads_changes_from_disk(config_dir: Path): + config_path = config_dir / "config.yml" + OmegaConf.save({"defaults": {"llm": "openai-llm"}}, config_path) + assert get_config(force_reload=True)["defaults"]["llm"] == "openai-llm" - result = merge_configs(defaults, overrides) + OmegaConf.save({"defaults": {"llm": "local-llm"}}, config_path) + reload_config() - # Defaults section merged - assert result["defaults"]["llm"] == "local-llm" # Override - assert result["defaults"]["stt"] == "stt-deepgram" # Preserved + assert get_config()["defaults"]["llm"] == "local-llm" - # Models list replaced - assert len(result["models"]) == 1 - assert result["models"][0]["name"] == "model3" - # Memory section deeply merged - assert result["memory"]["provider"] == "chronicle" # Preserved - assert result["memory"]["timeout_seconds"] == 1200 # Preserved - assert result["memory"]["extraction"]["enabled"] is True # Preserved - assert result["memory"]["extraction"]["prompt"] == "Custom prompt" # Override +def test_get_config_supports_absolute_config_file( + config_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + alternate_config = tmp_path / "alternate.yml" + OmegaConf.save({"defaults": {"stt": "stt-smallest"}}, alternate_config) + monkeypatch.setenv("CONFIG_FILE", str(alternate_config)) + config = get_config(force_reload=True) -if __name__ == "__main__": - pytest.main([__file__, "-v"]) + assert config["defaults"]["stt"] == "stt-smallest" diff --git a/tests/unit/test_docker_image_versioning.py b/tests/unit/test_docker_image_versioning.py new file mode 100644 index 00000000..3df90433 --- /dev/null +++ b/tests/unit/test_docker_image_versioning.py @@ -0,0 +1,380 @@ +"""Tests for the Docker image versioning & GHCR deployment feature. + +Covers three areas without requiring Docker or network access: + 1. services.py --use-prebuilt flag (argument parsing + env-var injection) + 2. docker-compose.yml files contain the expected image: fields + 3. push-images.sh / pull-images.sh reject missing inputs +""" + +import importlib +import os +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPTS_DIR = REPO_ROOT / "scripts" + +# --------------------------------------------------------------------------- +# Helper: import services.py from the repo root (it lives there, not in a pkg). +# services.py depends on python-dotenv and rich which may not be installed in +# the lightweight test environment, so we stub them at sys.modules level. +# --------------------------------------------------------------------------- + + +def _stub_missing(name: str, attrs: dict): + """Insert a minimal fake module under *name* if it isn't already importable.""" + if name in sys.modules: + return + fake = MagicMock() + for k, v in attrs.items(): + setattr(fake, k, v) + sys.modules[name] = fake + + +def _import_services(): + # Stub third-party deps that aren't installed in the bare test runner + _stub_missing("dotenv", {"dotenv_values": lambda path: {}}) + _stub_missing("rich", {}) + _stub_missing("rich.console", {"Console": MagicMock}) + _stub_missing("rich.table", {"Table": MagicMock}) + _stub_missing("setup_utils", {"read_env_value": lambda *a, **kw: None}) + + spec = importlib.util.spec_from_file_location("services", REPO_ROOT / "services.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# =========================================================================== +# 1. services.py — --use-prebuilt flag +# =========================================================================== + + +class TestUsePrebuiltFlag: + """Test the --use-prebuilt TAG argument added to services.py start command.""" + + def _run_start(self, argv, env_override=None): + """Import and run services.main() with given argv + env, mocking side effects.""" + services_mod = _import_services() + + override = env_override or {} + env = {**os.environ, **override} + # Remove CHRONICLE_* vars so we start clean, unless explicitly provided + if "CHRONICLE_REGISTRY" not in override: + env.pop("CHRONICLE_REGISTRY", None) + if "CHRONICLE_TAG" not in override: + env.pop("CHRONICLE_TAG", None) + + captured_calls = {} + + def fake_start_services(service_list, build, force_recreate): + captured_calls["service_list"] = service_list + captured_calls["build"] = build + captured_calls["force_recreate"] = force_recreate + # Capture env vars here while patch.dict is still active + captured_calls["CHRONICLE_REGISTRY"] = os.environ.get("CHRONICLE_REGISTRY") + captured_calls["CHRONICLE_TAG"] = os.environ.get("CHRONICLE_TAG") + + with ( + patch.object( + services_mod, "start_services", side_effect=fake_start_services + ), + patch.object(services_mod, "check_service_enabled", return_value=True), + patch.object(services_mod, "ensure_docker_network", return_value=True), + patch.object( + services_mod, "_langfuse_enabled_in_backend", return_value=False + ), + patch.dict(os.environ, env, clear=True), + patch.object(sys, "argv", argv), + ): + services_mod.main() + + return captured_calls + + def test_use_prebuilt_argument_is_accepted(self): + """--use-prebuilt is a valid argument that doesn't cause a parse error.""" + services_mod = _import_services() + # Drive it through argparse without executing side effects + with ( + patch.object(services_mod, "start_services"), + patch.object(services_mod, "check_service_enabled", return_value=True), + patch.object(services_mod, "ensure_docker_network", return_value=True), + patch.object( + services_mod, "_langfuse_enabled_in_backend", return_value=False + ), + patch.dict(os.environ, {}, clear=False), + patch.object( + sys, + "argv", + ["services.py", "start", "backend", "--use-prebuilt", "v1.0.0"], + ), + ): + # Should not raise + services_mod.main() + + def test_use_prebuilt_defaults_to_ghcr(self): + """Without DOCKERHUB_USERNAME or CHRONICLE_REGISTRY, defaults to GHCR.""" + calls = self._run_start( + ["services.py", "start", "--all", "--use-prebuilt", "v1.0.0"], + env_override={}, + ) + assert calls.get("CHRONICLE_REGISTRY") == "ghcr.io/simpleopensoftware/" + + def test_use_prebuilt_dockerhub_username_backward_compat(self): + """DOCKERHUB_USERNAME still works as fallback for --use-prebuilt.""" + calls = self._run_start( + ["services.py", "start", "--all", "--use-prebuilt", "v1.0.0"], + env_override={"DOCKERHUB_USERNAME": "myuser"}, + ) + assert calls.get("CHRONICLE_REGISTRY") == "myuser/" + + def test_use_prebuilt_chronicle_registry_takes_precedence(self): + """CHRONICLE_REGISTRY overrides both DOCKERHUB_USERNAME and the default.""" + calls = self._run_start( + ["services.py", "start", "--all", "--use-prebuilt", "v1.0.0"], + env_override={ + "CHRONICLE_REGISTRY": "ghcr.io/custom/", + "DOCKERHUB_USERNAME": "myuser", + }, + ) + assert calls.get("CHRONICLE_REGISTRY") == "ghcr.io/custom/" + + def test_use_prebuilt_sets_chronicle_tag_env_var(self): + """CHRONICLE_TAG is set to the supplied tag when --use-prebuilt is used.""" + calls = self._run_start( + ["services.py", "start", "--all", "--use-prebuilt", "v2.3.4"], + env_override={}, + ) + assert calls.get("CHRONICLE_TAG") == "v2.3.4" + + def test_use_prebuilt_disables_build_flag(self): + """start_services is called with build=False when --use-prebuilt is used.""" + calls = self._run_start( + ["services.py", "start", "--all", "--use-prebuilt", "v1.0.0"], + env_override={}, + ) + assert calls.get("build") is False + + def test_build_flag_still_works_without_use_prebuilt(self): + """--build flag still passes build=True to start_services when --use-prebuilt is absent.""" + calls = self._run_start( + ["services.py", "start", "--all", "--build"], + env_override={}, + ) + assert calls.get("build") is True + + def test_normal_start_without_prebuilt_does_not_set_chronicle_vars(self): + """CHRONICLE_REGISTRY and CHRONICLE_TAG are NOT set for a normal start.""" + env = {**os.environ} + env.pop("CHRONICLE_REGISTRY", None) + env.pop("CHRONICLE_TAG", None) + + with patch.dict(os.environ, env, clear=True): + self._run_start(["services.py", "start", "--all"]) + assert "CHRONICLE_REGISTRY" not in os.environ + assert "CHRONICLE_TAG" not in os.environ + + +# =========================================================================== +# 2. docker-compose YAML validation +# =========================================================================== + + +def _load_compose(relative_path: str) -> dict: + path = REPO_ROOT / relative_path + with open(path) as f: + return yaml.safe_load(f) + + +def _image_for(compose: dict, service: str) -> str | None: + return compose.get("services", {}).get(service, {}).get("image") + + +def _has_chronicle_vars(image_str: str | None) -> bool: + """True when the image field uses both CHRONICLE_REGISTRY and CHRONICLE_TAG.""" + if image_str is None: + return False + return "CHRONICLE_REGISTRY" in image_str and "CHRONICLE_TAG" in image_str + + +class TestBackendDockerComposeImages: + COMPOSE = _load_compose("backends/advanced/docker-compose.yml") + + def test_chronicle_backend_has_image_field(self): + assert _has_chronicle_vars(_image_for(self.COMPOSE, "chronicle-backend")) + + def test_workers_has_image_field(self): + assert _has_chronicle_vars(_image_for(self.COMPOSE, "workers")) + + def test_annotation_cron_has_image_field(self): + assert _has_chronicle_vars(_image_for(self.COMPOSE, "annotation-cron")) + + def test_webui_dev_is_built_locally(self): + webui = self.COMPOSE["services"]["webui-dev"] + assert "build" in webui + assert "image" not in webui + + def test_backend_services_share_same_image_name(self): + """chronicle-backend, workers, and annotation-cron should use the same image.""" + backend_img = _image_for(self.COMPOSE, "chronicle-backend") + workers_img = _image_for(self.COMPOSE, "workers") + cron_img = _image_for(self.COMPOSE, "annotation-cron") + assert backend_img == workers_img == cron_img + + def test_image_names_with_defaults_are_local(self): + """With empty env vars the image names should have no registry prefix.""" + for service in ("chronicle-backend", "workers", "annotation-cron"): + image = _image_for(self.COMPOSE, service) + # The default expansion of ${CHRONICLE_REGISTRY:-} is "" + # so the name should start with "chronicle-" + assert image is not None + assert "chronicle-" in image + + +class TestSpeakerRecognitionDockerComposeImages: + COMPOSE = _load_compose("extras/speaker-recognition/docker-compose.yml") + + def test_speaker_service_has_chronicle_image(self): + assert _has_chronicle_vars(_image_for(self.COMPOSE, "speaker-service")) + + def test_web_ui_has_chronicle_image(self): + assert _has_chronicle_vars(_image_for(self.COMPOSE, "web-ui")) + + def test_caddy_image_is_not_changed(self): + """Third-party caddy image must stay unchanged (no chronicle vars).""" + caddy_img = _image_for(self.COMPOSE, "caddy") + assert caddy_img is not None + assert "CHRONICLE_REGISTRY" not in (caddy_img or "") + + +class TestAsrServicesDockerComposeImages: + COMPOSE = _load_compose("extras/asr-services/docker-compose.yml") + + @pytest.mark.parametrize( + "service", + [ + "nemo-asr", + "faster-whisper-asr", + "vibevoice-asr", + "transformers-asr", + "qwen3-asr-wrapper", + "qwen3-asr-bridge", + ], + ) + def test_asr_service_has_chronicle_image(self, service): + assert _has_chronicle_vars( + _image_for(self.COMPOSE, service) + ), f"Service '{service}' is missing CHRONICLE_REGISTRY/CHRONICLE_TAG in image: field" + + def test_all_asr_images_are_distinct(self): + """Each ASR service must resolve to a different image name.""" + services = [ + "nemo-asr", + "faster-whisper-asr", + "vibevoice-asr", + "transformers-asr", + "qwen3-asr-wrapper", + "qwen3-asr-bridge", + ] + images = [_image_for(self.COMPOSE, s) for s in services] + assert len(images) == len( + set(images) + ), "ASR service image names must all be unique" + + +class TestHavpeRelayDockerComposeImages: + COMPOSE = _load_compose("extras/havpe-relay/docker-compose.yml") + + def test_havpe_relay_has_chronicle_image(self): + assert _has_chronicle_vars(_image_for(self.COMPOSE, "havpe-relay")) + + +# =========================================================================== +# 3. Bash script input validation +# =========================================================================== + + +class TestPushScriptValidation: + """push-images.sh must reject missing inputs without running docker.""" + + SCRIPT = SCRIPTS_DIR / "push-images.sh" + + def _run(self, args: list[str], env_override: dict | None = None): + env = {**os.environ, **(env_override or {})} + env.pop("DOCKERHUB_USERNAME", None) # start clean + env.pop("CHRONICLE_PUSH_REGISTRY", None) # start clean + if env_override: + env.update(env_override) + return subprocess.run( + ["bash", str(self.SCRIPT)] + args, + env=env, + capture_output=True, + text=True, + ) + + def test_exits_nonzero_without_any_registry_env(self): + result = self._run(["v1.0.0"]) + assert result.returncode != 0 + + def test_error_message_mentions_registry_options(self): + result = self._run(["v1.0.0"]) + assert ( + "CHRONICLE_PUSH_REGISTRY" in result.stderr + or "DOCKERHUB_USERNAME" in result.stderr + ) + + def test_exits_nonzero_without_tag(self): + result = self._run([], env_override={"DOCKERHUB_USERNAME": "testuser"}) + assert result.returncode != 0 + + def test_error_message_mentions_tag_when_tag_missing(self): + result = self._run([], env_override={"DOCKERHUB_USERNAME": "testuser"}) + assert "TAG" in result.stderr + + def test_script_is_executable(self): + assert os.access(self.SCRIPT, os.X_OK), "push-images.sh must be executable" + + +class TestPullScriptValidation: + """pull-images.sh defaults to GHCR and rejects missing TAG.""" + + SCRIPT = SCRIPTS_DIR / "pull-images.sh" + + def _run(self, args: list[str], env_override: dict | None = None): + env = {**os.environ, **(env_override or {})} + env.pop("DOCKERHUB_USERNAME", None) + env.pop("CHRONICLE_REGISTRY", None) + if env_override: + env.update(env_override) + return subprocess.run( + ["bash", str(self.SCRIPT)] + args, + env=env, + capture_output=True, + text=True, + ) + + def test_exits_nonzero_without_tag(self): + result = self._run([]) + assert result.returncode != 0 + + def test_error_message_mentions_tag_when_tag_missing(self): + result = self._run([]) + assert "TAG" in result.stderr + + def test_defaults_to_ghcr_without_env_vars(self): + """pull-images.sh should NOT error when no DOCKERHUB_USERNAME is set (GHCR default).""" + # We can't actually pull, but we can verify the script doesn't exit + # at the validation stage. It will fail at docker pull which is fine. + result = self._run(["v1.0.0"]) + # Should not fail at the input validation stage (returncode 1 with our error message) + # It will fail later at docker pull, but the stderr should not contain our validation errors + assert "DOCKERHUB_USERNAME" not in result.stderr + assert "env var is required" not in result.stderr + + def test_script_is_executable(self): + assert os.access(self.SCRIPT, os.X_OK), "pull-images.sh must be executable" diff --git a/tests/unit/test_updates.py b/tests/unit/test_updates.py new file mode 100644 index 00000000..621c0bc6 --- /dev/null +++ b/tests/unit/test_updates.py @@ -0,0 +1,261 @@ +"""Tests for updates.py — node version reporting + self-update. + +Exercises the git orchestration against scratch repos (a local "origin" plus a +clone standing in for a node checkout). No Docker and no network: service +restarts are stubbed out, so perform_update()'s checkout/rollback logic is +what's under test. +""" + +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + + +def _stub_missing(name: str, attrs: dict): + """Insert a minimal fake module under *name* if it isn't already importable.""" + if name in sys.modules: + return + try: + __import__(name) + except ImportError: + fake = MagicMock() + for k, v in attrs.items(): + setattr(fake, k, v) + sys.modules[name] = fake + + +# Stub third-party deps services.py needs that aren't in the bare test runner +# (same set as test_docker_image_versioning.py). +_stub_missing("dotenv", {"dotenv_values": lambda path: {}}) +_stub_missing("rich", {}) +_stub_missing("rich.console", {"Console": MagicMock}) +_stub_missing("rich.markup", {"escape": lambda s: s}) +_stub_missing("rich.table", {"Table": MagicMock}) +_stub_missing("setup_utils", {"read_env_value": lambda *a, **kw: None}) + +import updates # noqa: E402 (needs the stubs + sys.path above) + + +def _git(cwd: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], cwd=cwd, capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + +def _commit(repo: Path, filename: str, message: str): + (repo / filename).write_text(message) + _git(repo, "add", "-A") + _git(repo, "commit", "-m", message) + + +@pytest.fixture() +def origin_and_clone(tmp_path, monkeypatch): + """A local origin with one commit + tag v0.1.0, and a clone of it. + + updates.REPO_ROOT is pointed at the clone (the "node checkout"). + """ + origin = tmp_path / "origin" + origin.mkdir() + _git(origin, "init", "-b", "main") + _git(origin, "config", "user.email", "test@test") + _git(origin, "config", "user.name", "test") + _commit(origin, "file.txt", "one") + _git(origin, "tag", "v0.1.0") + + clone = tmp_path / "clone" + _git(tmp_path, "clone", str(origin), str(clone)) + _git(clone, "config", "user.email", "test@test") + _git(clone, "config", "user.name", "test") + + monkeypatch.setattr(updates, "REPO_ROOT", clone) + return origin, clone + + +class TestRepoVersion: + def test_branch_checkout(self, origin_and_clone): + _origin, clone = origin_and_clone + v = updates.repo_version() + assert v["describe"] == "v0.1.0" + assert v["branch"] == "main" + assert v["dirty"] is False + assert v["commit"] == _git(clone, "rev-parse", "--short", "HEAD") + + def test_detached_and_dirty(self, origin_and_clone): + _origin, clone = origin_and_clone + _git(clone, "checkout", "--detach", "v0.1.0") + (clone / "file.txt").write_text("local edit") + v = updates.repo_version() + assert v["branch"] is None + assert v["dirty"] is True + assert v["describe"].endswith("-dirty") + + +class TestCheckUpdate: + def test_branch_mode_up_to_date(self, origin_and_clone): + info = updates.check_update() + assert info["target"]["kind"] == "branch" + assert info["target"]["ref"] == "origin/main" + assert info["update_available"] is False + assert "error" not in info + + def test_branch_mode_behind(self, origin_and_clone): + origin, _clone = origin_and_clone + _commit(origin, "file.txt", "two") + info = updates.check_update() + assert info["update_available"] is True + + def test_branch_mode_ahead_only(self, origin_and_clone): + """Local unpushed commits don't count as an available update.""" + _origin, clone = origin_and_clone + _commit(clone, "local.txt", "local work") + info = updates.check_update() + assert info["update_available"] is False + + def test_release_mode_new_tag(self, origin_and_clone): + origin, clone = origin_and_clone + _git(clone, "checkout", "--detach", "v0.1.0") + _commit(origin, "file.txt", "two") + _git(origin, "tag", "v0.2.0") + info = updates.check_update() + assert info["target"] == { + "ref": "v0.2.0", + "kind": "tag", + "commit": _git(origin, "rev-parse", "--short", "v0.2.0"), + } + assert info["update_available"] is True + + def test_semver_ordering_not_lexicographic(self, origin_and_clone): + origin, clone = origin_and_clone + _git(clone, "checkout", "--detach", "v0.1.0") + for tag in ("v0.2.0", "v0.10.0", "v0.9.1"): + _commit(origin, "file.txt", tag) + _git(origin, "tag", tag) + info = updates.check_update() + assert info["target"]["ref"] == "v0.10.0" + + def test_explicit_target(self, origin_and_clone): + origin, _clone = origin_and_clone + _commit(origin, "file.txt", "two") + _git(origin, "tag", "v0.2.0") + _commit(origin, "file.txt", "three") + _git(origin, "tag", "v0.3.0") + info = updates.check_update(target="v0.2.0") + assert info["target"]["ref"] == "v0.2.0" + assert info["update_available"] is True + + def test_unknown_target_reports_error(self, origin_and_clone): + info = updates.check_update(target="v9.9.9") + assert "error" in info + assert info["update_available"] is False + + +class TestPerformUpdate: + def _quiet(self, monkeypatch): + """Silence console output and stub the service-restart layer.""" + monkeypatch.setattr(updates.services, "console", MagicMock()) + + def test_branch_pull(self, origin_and_clone, monkeypatch): + origin, clone = origin_and_clone + self._quiet(monkeypatch) + _commit(origin, "file.txt", "two") + ok = updates.perform_update(restart_services=False) + assert ok is True + assert (clone / "file.txt").read_text() == "two" + # Still on the branch (pull, not a detached checkout). + assert updates.repo_version()["branch"] == "main" + + def test_release_checkout(self, origin_and_clone, monkeypatch): + origin, clone = origin_and_clone + self._quiet(monkeypatch) + _git(clone, "checkout", "--detach", "v0.1.0") + _commit(origin, "file.txt", "two") + _git(origin, "tag", "v0.2.0") + ok = updates.perform_update(restart_services=False) + assert ok is True + assert updates.repo_version()["describe"] == "v0.2.0" + + def test_already_up_to_date_skips_restarts(self, origin_and_clone, monkeypatch): + self._quiet(monkeypatch) + restarts = MagicMock() + monkeypatch.setattr(updates, "_restart_enabled_services", restarts) + ok = updates.perform_update(restart_services=True) + assert ok is True + restarts.assert_not_called() + + def test_dirty_release_checkout_refused(self, origin_and_clone, monkeypatch): + origin, clone = origin_and_clone + self._quiet(monkeypatch) + _git(clone, "checkout", "--detach", "v0.1.0") + _commit(origin, "file.txt", "two") + _git(origin, "tag", "v0.2.0") + (clone / "file.txt").write_text("uncommitted") + prev = _git(clone, "rev-parse", "HEAD") + ok = updates.perform_update(restart_services=False) + assert ok is False + assert _git(clone, "rev-parse", "HEAD") == prev + # The dirty tree is untouched. + assert (clone / "file.txt").read_text() == "uncommitted" + + def test_rollback_on_service_failure(self, origin_and_clone, monkeypatch): + origin, clone = origin_and_clone + self._quiet(monkeypatch) + _git(clone, "checkout", "--detach", "v0.1.0") + _commit(origin, "file.txt", "two") + _git(origin, "tag", "v0.2.0") + prev = _git(clone, "rev-parse", "HEAD") + + monkeypatch.setattr(updates, "_enabled_services", lambda: ["backend"]) + monkeypatch.setattr( + updates.services, "run_compose_command", lambda *a, **kw: False + ) + ok = updates.perform_update() + assert ok is False + # Checkout was rolled back to the pre-update commit. + assert _git(clone, "rev-parse", "HEAD") == prev + + def test_service_success_keeps_new_code(self, origin_and_clone, monkeypatch): + origin, clone = origin_and_clone + self._quiet(monkeypatch) + _commit(origin, "file.txt", "two") + + calls = [] + monkeypatch.setattr(updates, "_enabled_services", lambda: ["backend"]) + monkeypatch.setattr( + updates.services, + "run_compose_command", + lambda name, cmd, **kw: calls.append((name, cmd, kw)) or True, + ) + ok = updates.perform_update() + assert ok is True + assert calls == [("backend", "up", {"build": True})] + assert (clone / "file.txt").read_text() == "two" + + def test_prebuilt_uses_registry_env(self, origin_and_clone, monkeypatch): + origin, _clone = origin_and_clone + self._quiet(monkeypatch) + _commit(origin, "file.txt", "two") + monkeypatch.delenv("CHRONICLE_REGISTRY", raising=False) + monkeypatch.delenv("CHRONICLE_TAG", raising=False) + + calls = [] + monkeypatch.setattr(updates, "_enabled_services", lambda: ["backend"]) + monkeypatch.setattr( + updates.services, + "run_compose_command", + lambda name, cmd, **kw: calls.append(kw) or True, + ) + ok = updates.perform_update(prebuilt="v0.2.0") + assert ok is True + # Prebuilt → no local build, registry env set for compose to consume. + assert calls == [{"build": False}] + import os + + assert os.environ["CHRONICLE_TAG"] == "v0.2.0" + assert os.environ["CHRONICLE_REGISTRY"].startswith("ghcr.io/") diff --git a/tests/unit/test_wizard_defaults.py b/tests/unit/test_wizard_defaults.py new file mode 100644 index 00000000..fef0c004 --- /dev/null +++ b/tests/unit/test_wizard_defaults.py @@ -0,0 +1,152 @@ +"""Test wizard.py helper functions for loading previous config as defaults. + +Tests for the functions that read config/config.yml to pre-populate wizard +prompts with previously-configured values, so re-runs default to existing +settings. +""" + +import importlib.util +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +# --------------------------------------------------------------------------- +# Import the pure helper functions directly from wizard.py. +# wizard.py lives at the project root, not inside a package, so we import +# via importlib with an explicit path to avoid adding the root to sys.path +# permanently. +# --------------------------------------------------------------------------- + + +WIZARD_PATH = Path(__file__).parent.parent.parent / "wizard.py" +PROJECT_ROOT = str(WIZARD_PATH.parent) + + +def _load_wizard(): + # wizard.py and setup_utils.py both live in the project root. + # Add the root to sys.path so the relative import resolves. + if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + spec = importlib.util.spec_from_file_location("wizard", WIZARD_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# Load once and reuse +_wizard = _load_wizard() + +get_existing_stt_provider = _wizard.get_existing_stt_provider +get_existing_stream_provider = _wizard.get_existing_stream_provider +select_llm_provider = _wizard.select_llm_provider + + +# --------------------------------------------------------------------------- +# get_existing_stt_provider +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "stt_value, expected", + [ + ("stt-deepgram", "deepgram"), + ("stt-deepgram-stream", "deepgram"), + ("stt-parakeet-batch", "parakeet"), + ("stt-vibevoice", "vibevoice"), + ("stt-qwen3-asr", "qwen3-asr"), + ("stt-smallest", "smallest"), + ("stt-smallest-stream", "smallest"), + ], +) +def test_get_existing_stt_provider_known_values(stt_value, expected): + """Maps known config.yml stt values to wizard provider names.""" + config = {"defaults": {"stt": stt_value}} + assert get_existing_stt_provider(config) == expected + + +def test_get_existing_stt_provider_unknown_returns_none(): + """Returns None for unknown stt values (e.g. custom providers).""" + config = {"defaults": {"stt": "stt-unknown-provider"}} + assert get_existing_stt_provider(config) is None + + +def test_get_existing_stt_provider_missing_key(): + """Returns None when defaults.stt key is absent.""" + assert get_existing_stt_provider({}) is None + assert get_existing_stt_provider({"defaults": {}}) is None + + +# --------------------------------------------------------------------------- +# get_existing_stream_provider +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "stt_stream_value, expected", + [ + ("stt-deepgram-stream", "deepgram"), + ("stt-smallest-stream", "smallest"), + ("stt-qwen3-asr", "qwen3-asr"), + ("stt-qwen3-asr-stream", "qwen3-asr"), + ], +) +def test_get_existing_stream_provider_known_values(stt_stream_value, expected): + """Maps known config.yml stt_stream values to wizard streaming provider names.""" + config = {"defaults": {"stt_stream": stt_stream_value}} + assert get_existing_stream_provider(config) == expected + + +def test_get_existing_stream_provider_unknown_returns_none(): + """Returns None for unknown stt_stream values.""" + config = {"defaults": {"stt_stream": "stt-unknown"}} + assert get_existing_stream_provider(config) is None + + +def test_get_existing_stream_provider_missing_key(): + """Returns None when defaults.stt_stream is absent.""" + assert get_existing_stream_provider({}) is None + assert get_existing_stream_provider({"defaults": {}}) is None + + +# --------------------------------------------------------------------------- +# select_llm_provider — test default resolution logic via EOFError path +# --------------------------------------------------------------------------- + + +def _select_llm_with_eof(config_yml): + """Drive select_llm_provider in non-interactive mode by injecting EOFError.""" + with ( + patch.object(_wizard, "Confirm") as mock_confirm, + patch.object(_wizard, "Prompt") as mock_prompt, + ): + mock_confirm.ask.side_effect = EOFError + mock_prompt.ask.side_effect = EOFError + return select_llm_provider(config_yml) + + +def test_select_llm_provider_defaults_to_openai_when_no_config(): + """Defaults to openai when config is empty.""" + result = _select_llm_with_eof({}) + assert result == "openai" + + +def test_select_llm_provider_defaults_to_openai_for_openai_llm(): + """Picks openai when existing config has defaults.llm = openai-llm.""" + config = {"defaults": {"llm": "openai-llm"}} + result = _select_llm_with_eof(config) + assert result == "openai" + + +def test_select_llm_provider_defaults_to_ollama_for_local_llm(): + """Picks ollama when existing config has defaults.llm = local-llm.""" + config = {"defaults": {"llm": "local-llm"}} + result = _select_llm_with_eof(config) + assert result == "ollama" + + +def test_select_llm_provider_none_config(): + """Treats None config_yml as empty dict (defaults to openai).""" + result = _select_llm_with_eof(None) + assert result == "openai" diff --git a/tests/unit/test_wizard_strixhalo.py b/tests/unit/test_wizard_strixhalo.py new file mode 100644 index 00000000..852c6775 --- /dev/null +++ b/tests/unit/test_wizard_strixhalo.py @@ -0,0 +1,187 @@ +import argparse +import importlib.util +import sys +import types +from pathlib import Path + + +def _install_rich_stubs(): + rich_mod = types.ModuleType("rich") + rich_console_mod = types.ModuleType("rich.console") + rich_prompt_mod = types.ModuleType("rich.prompt") + + class _Console: + def print(self, *args, **kwargs): + return None + + class _Confirm: + @staticmethod + def ask(*args, **kwargs): + return kwargs.get("default", False) + + class _Prompt: + @staticmethod + def ask(*args, **kwargs): + return kwargs.get("default", "") + + rich_console_mod.Console = _Console + rich_prompt_mod.Confirm = _Confirm + rich_prompt_mod.Prompt = _Prompt + + sys.modules.setdefault("rich", rich_mod) + sys.modules.setdefault("rich.console", rich_console_mod) + sys.modules.setdefault("rich.prompt", rich_prompt_mod) + + +def _install_setup_utils_stub(): + setup_utils_mod = types.ModuleType("setup_utils") + setup_utils_mod.detect_tailscale_info = lambda: (None, None) + setup_utils_mod.decide_cert_mode = lambda *_: "caddy" + setup_utils_mod.generate_tailscale_certs = lambda *_: False + setup_utils_mod.is_placeholder = lambda value, *placeholders: value in placeholders + setup_utils_mod.mask_value = lambda value, *_: value + setup_utils_mod.prompt_password = lambda *_, **__: "" + setup_utils_mod.prompt_with_existing_masked = ( + lambda *_, existing_value=None, default="", **__: existing_value or default + ) + setup_utils_mod.read_env_value = lambda *_: None + sys.modules.setdefault("setup_utils", setup_utils_mod) + + +def _load_wizard_module(): + _install_rich_stubs() + _install_setup_utils_stub() + module_path = Path(__file__).resolve().parents[2] / "wizard.py" + repo_root = str(module_path.parent) + if repo_root not in sys.path: + sys.path.insert(0, repo_root) + spec = importlib.util.spec_from_file_location("wizard_module", module_path) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + return module + + +wizard = _load_wizard_module() + + +def _mock_read_env_value(path, key): + if key == "PYTORCH_CUDA_VERSION": + return "cu126" + if key == "COMPUTE_MODE": + return "cpu" + return None + + +def test_run_service_setup_asr_uses_strix_provider_and_runtime(monkeypatch): + captured = {} + + def fake_run(cmd, cwd, check, timeout): + captured["cmd"] = cmd + return argparse.Namespace(returncode=0) + + monkeypatch.setattr(wizard, "check_service_exists", lambda *_: (True, "OK")) + monkeypatch.setattr(wizard, "read_env_value", _mock_read_env_value) + monkeypatch.setattr(wizard.subprocess, "run", fake_run) + + ok = wizard.run_service_setup( + service_name="asr-services", + selected_services=["advanced", "asr-services"], + transcription_provider="parakeet", + hardware_profile="strixhalo", + ) + + assert ok is True + cmd = captured["cmd"] + assert "--provider" in cmd + provider_idx = cmd.index("--provider") + 1 + assert cmd[provider_idx] == "nemo-strixhalo" + assert cmd[provider_idx] != "nemo" + assert "--pytorch-cuda-version" in cmd + runtime_idx = cmd.index("--pytorch-cuda-version") + 1 + assert cmd[runtime_idx] == "strixhalo" + assert cmd[runtime_idx] != "cu126" + + +def test_run_service_setup_asr_vibevoice_uses_strix_variant(monkeypatch): + captured = {} + + def fake_run(cmd, cwd, check, timeout): + captured["cmd"] = cmd + return argparse.Namespace(returncode=0) + + monkeypatch.setattr(wizard, "check_service_exists", lambda *_: (True, "OK")) + monkeypatch.setattr(wizard, "read_env_value", _mock_read_env_value) + monkeypatch.setattr(wizard.subprocess, "run", fake_run) + + ok = wizard.run_service_setup( + service_name="asr-services", + selected_services=["advanced", "asr-services"], + transcription_provider="vibevoice", + hardware_profile="strixhalo", + ) + + assert ok is True + cmd = captured["cmd"] + assert "--provider" in cmd + provider_idx = cmd.index("--provider") + 1 + assert cmd[provider_idx] == "vibevoice-strixhalo" + assert cmd[provider_idx] != "vibevoice" + assert "--pytorch-cuda-version" in cmd + runtime_idx = cmd.index("--pytorch-cuda-version") + 1 + assert cmd[runtime_idx] == "strixhalo" + assert cmd[runtime_idx] != "cu126" + + +def test_run_service_setup_speaker_forces_strix_compute(monkeypatch): + captured = {} + + def fake_run(cmd, cwd, check, timeout): + captured["cmd"] = cmd + return argparse.Namespace(returncode=0) + + monkeypatch.setattr(wizard, "check_service_exists", lambda *_: (True, "OK")) + monkeypatch.setattr(wizard, "read_env_value", _mock_read_env_value) + monkeypatch.setattr(wizard.subprocess, "run", fake_run) + + ok = wizard.run_service_setup( + service_name="speaker-recognition", + selected_services=["advanced", "speaker-recognition"], + hf_token="hf_token", + hardware_profile="strixhalo", + ) + + assert ok is True + cmd = captured["cmd"] + assert "--pytorch-cuda-version" in cmd + runtime_idx = cmd.index("--pytorch-cuda-version") + 1 + assert cmd[runtime_idx] == "strixhalo" + compute_pairs = [ + cmd[i + 1] for i, v in enumerate(cmd[:-1]) if v == "--compute-mode" + ] + assert "gpu" in compute_pairs + assert "cpu" not in compute_pairs + + +def test_select_hardware_profile_returns_strix(monkeypatch): + monkeypatch.setattr(wizard.Prompt, "ask", lambda *args, **kwargs: "2") + + result = wizard.select_hardware_profile( + selected_services=["speaker-recognition"], + transcription_provider="deepgram", + streaming_provider=None, + ) + + assert result == "strixhalo" + + +def test_select_hardware_profile_skips_when_not_needed(monkeypatch): + monkeypatch.setattr(wizard.Prompt, "ask", lambda *args, **kwargs: "2") + + result = wizard.select_hardware_profile( + selected_services=["advanced"], + transcription_provider="deepgram", + streaming_provider=None, + ) + + assert result is None diff --git a/updates.py b/updates.py new file mode 100644 index 00000000..a4b2b473 --- /dev/null +++ b/updates.py @@ -0,0 +1,279 @@ +""" +Node code-version reporting + self-update. + +A Chronicle node is a git checkout driven by services.py, so version truth is +``git describe`` on the checkout. Updates move the checkout, then rebuild and +restart the enabled services from the new code. Two modes, mirroring how nodes +are installed: + + - branch mode: HEAD is on a branch with an upstream (dev checkouts, + edge/install.sh --branch installs) → ``git pull --rebase + --autostash`` on that branch. + - release mode: HEAD is detached (the root install.sh clones a release tag) + or an explicit target tag was given → fetch tags and check + out the target (latest ``v*`` tag by default). + +On a compose failure after the checkout moved, the checkout is rolled back to +the previous commit (detached, so local branches are never rewritten) and the +services are restarted from the old code. + +Used by ``services.py update`` (CLI) and the node agent's ``/update`` routes +(edge/service_manager.py), which the hub fans out across the cluster. +""" + +import os +import re +import subprocess +from pathlib import Path + +import services + +REPO_ROOT = Path(__file__).resolve().parent + +# Fetches hit the network; builds after an update can take minutes and stream +# through services.py's own machinery, so only git itself needs a timeout here. +_GIT_TIMEOUT = 60 + +_RELEASE_TAG_RE = re.compile(r"^v(\d+(?:\.\d+)*)$") + + +class UpdateError(Exception): + """A git step failed in a way that should abort the update.""" + + +def _git(*args: str, check: bool = False) -> subprocess.CompletedProcess: + result = subprocess.run( + ["git", *args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + ) + if check and result.returncode != 0: + raise UpdateError( + f"git {' '.join(args)} failed: {(result.stderr or result.stdout).strip()}" + ) + return result + + +def _git_out(*args: str) -> str: + """Stdout of a git command, '' on failure.""" + result = _git(*args) + return result.stdout.strip() if result.returncode == 0 else "" + + +def repo_version() -> dict: + """This checkout's identity: {describe, commit, branch, dirty}. + + ``branch`` is None when HEAD is detached (release-tag installs). ``describe`` + falls back to the short commit on tagless clones (--always). + """ + branch = _git_out("rev-parse", "--abbrev-ref", "HEAD") + return { + "describe": _git_out("describe", "--tags", "--always", "--dirty"), + "commit": _git_out("rev-parse", "--short", "HEAD"), + "branch": None if branch in ("", "HEAD") else branch, + "dirty": bool(_git_out("status", "--porcelain")), + } + + +def _latest_release_tag() -> str | None: + """Highest ``vX[.Y[.Z]]`` tag known locally (fetch tags first).""" + tags = [t for t in _git_out("tag", "-l", "v*").splitlines() if t] + versioned = [ + (tuple(int(p) for p in m.group(1).split(".")), t) + for t in tags + if (m := _RELEASE_TAG_RE.match(t)) + ] + return max(versioned)[1] if versioned else None + + +def _resolve_target(target: str | None) -> dict: + """What this node should update to: {ref, kind, commit}. + + Explicit ``target`` wins (a tag or any ref). Otherwise branch mode when HEAD + has an upstream, else the latest release tag. Raises UpdateError when no + target can be determined (tagless clone with no upstream). + """ + if target: + commit = _git_out("rev-parse", "--short", f"{target}^{{commit}}") + if not commit: + raise UpdateError(f"Unknown update target {target!r} (not a ref or tag)") + kind = ( + "tag" if _git_out("rev-parse", "--verify", f"refs/tags/{target}") else "ref" + ) + return {"ref": target, "kind": kind, "commit": commit} + + upstream = _git_out("rev-parse", "--abbrev-ref", "@{u}") + if repo_version()["branch"] and upstream: + return { + "ref": upstream, + "kind": "branch", + "commit": _git_out("rev-parse", "--short", upstream), + } + + latest = _latest_release_tag() + if not latest: + raise UpdateError( + "No update target: HEAD has no upstream branch and no v* release tags exist" + ) + return { + "ref": latest, + "kind": "tag", + "commit": _git_out("rev-parse", "--short", f"{latest}^{{commit}}"), + } + + +def check_update(target: str | None = None, fetch: bool = True) -> dict: + """Compare this checkout against its update target (fetches by default). + + Returns {current, target, update_available} and never raises — an ``error`` + key reports fetch/resolution problems instead, so agent /update checks stay + a clean JSON round-trip even on offline nodes. + """ + result: dict = { + "current": repo_version(), + "target": None, + "update_available": False, + } + try: + if fetch: + # --force: take origin's tags as truth — git ≥2.20 otherwise refuses + # to move a local tag that diverged ("would clobber existing tag"), + # which stale tags from renamed/forked origins trigger. + fetched = _git("fetch", "--tags", "--force", "origin") + if fetched.returncode != 0: + result["error"] = ( + f"git fetch failed: {(fetched.stderr or fetched.stdout).strip()}" + ) + return result + resolved = _resolve_target(target) + except (UpdateError, subprocess.TimeoutExpired) as e: + result["error"] = str(e) + return result + + result["target"] = resolved + head = _git_out("rev-parse", "--short", "HEAD") + if resolved["kind"] == "branch": + behind = _git_out("rev-list", "--count", f"HEAD..{resolved['ref']}") + result["update_available"] = bool(behind) and int(behind) > 0 + else: + result["update_available"] = ( + bool(resolved["commit"]) and resolved["commit"] != head + ) + return result + + +def _enabled_services() -> list[str]: + """The services this node runs — same set ``services.py start --all`` uses.""" + return [ + s + for s in services.SERVICES + if services.check_service_enabled(s) + or (s == "langfuse" and services._langfuse_enabled_in_backend()) + ] + + +def _apply_checkout(resolved: dict, progress) -> None: + """Move the checkout to the resolved target. Raises UpdateError on failure.""" + if resolved["kind"] == "branch": + # Rebase + autostash mirrors edge/install.sh: local commits are replayed, + # uncommitted changes are stashed around the pull. + progress(f"Pulling {resolved['ref']}…") + _git("pull", "--rebase", "--autostash", check=True) + else: + # A tag/ref checkout can't carry uncommitted changes across safely — + # refuse instead of guessing (branch mode handles the dirty case). + if repo_version()["dirty"]: + raise UpdateError( + "Checkout has uncommitted changes — commit/stash them, or update " + "a branch checkout instead" + ) + progress(f"Checking out {resolved['ref']}…") + _git("checkout", "--detach", resolved["ref"], check=True) + + +def _restart_enabled_services(build: bool, progress) -> str | None: + """``up`` every enabled service; returns the first failing service or None.""" + for name in _enabled_services(): + progress(f"Restarting {name}…") + if not services.run_compose_command(name, "up", build=build): + return name + return None + + +def perform_update( + target: str | None = None, + prebuilt: str | None = None, + restart_services: bool = True, + progress=None, +) -> bool: + """Update this node's code and restart its services from the new checkout. + + ``target`` — explicit tag/ref; default resolves per _resolve_target(). + ``prebuilt`` — image tag: pull ``CHRONICLE_REGISTRY`` images at that tag + instead of building locally (same env contract as + ``services.py start --use-prebuilt``). + ``progress`` — optional callable(str) for step-by-step phase reporting + (the node agent surfaces it to the WebUI). + + Rollback: if a service fails to come up on the new code, the checkout is + restored to the previous commit (detached — local branches are never + rewritten) and the services are restarted from the old code. + """ + progress = progress or (lambda msg: services.console.print(f"[cyan]{msg}[/cyan]")) + + progress("Fetching updates…") + try: + # --force: take origin's tags as truth (see check_update). + _git("fetch", "--tags", "--force", "origin", check=True) + resolved = _resolve_target(target) + prev_commit = _git_out("rev-parse", "HEAD") + + head_before = _git_out("rev-parse", "--short", "HEAD") + if resolved["commit"] == head_before: + progress(f"Already up to date at {repo_version()['describe']}") + return True + + _apply_checkout(resolved, progress) + except (UpdateError, subprocess.TimeoutExpired) as e: + services.console.print(f"[red]❌ Update failed: {e}[/red]") + return False + + services.console.print( + f"[green]✅ Code updated to {repo_version()['describe']}[/green]" + ) + if not restart_services: + return True + + if prebuilt: + # Same env contract the compose files consume for prebuilt images. + os.environ.setdefault("CHRONICLE_REGISTRY", "ghcr.io/simpleopensoftware/") + os.environ["CHRONICLE_TAG"] = prebuilt + + failed = _restart_enabled_services(build=not prebuilt, progress=progress) + if failed is None: + return True + + # Roll back: old code, old services. Best-effort — report both outcomes. + services.console.print( + f"[red]❌ {failed} failed to start on the new code — rolling back to " + f"{prev_commit[:8]}[/red]" + ) + progress(f"Rolling back to {prev_commit[:8]}…") + rollback = _git("checkout", "--detach", prev_commit) + if rollback.returncode != 0: + services.console.print( + f"[red]❌ Rollback checkout failed: {rollback.stderr.strip()} — " + "manual intervention needed[/red]" + ) + return False + refailed = _restart_enabled_services(build=not prebuilt, progress=progress) + if refailed: + services.console.print( + f"[red]❌ {refailed} also failed on the previous code — the update " + "did not cause this; check the service logs[/red]" + ) + else: + services.console.print("[yellow]↩️ Rolled back; services restored[/yellow]") + return False diff --git a/wizard.py b/wizard.py index b04f028c..60a28ea3 100755 --- a/wizard.py +++ b/wizard.py @@ -6,58 +6,173 @@ import shutil import subprocess -from datetime import datetime from pathlib import Path -import yaml +import discovery +import services +from config_manager import ConfigManager +from dotenv import set_key from rich.console import Console from rich.prompt import Confirm, Prompt # Import shared setup utilities from setup_utils import ( + decide_cert_mode, detect_tailscale_info, + enable_tailscaled_at_boot, + generate_tailscale_certs, is_placeholder, mask_value, prompt_password, prompt_with_existing_masked, read_env_value, + tailscaled_enabled_at_boot, ) console = Console() + +def get_existing_stt_provider(config_yml: dict): + """Map config.yml defaults.stt value back to wizard provider name, or None.""" + stt = config_yml.get("defaults", {}).get("stt", "") + mapping = { + "stt-deepgram": "deepgram", + "stt-deepgram-stream": "deepgram", + "stt-parakeet-batch": "parakeet", + "stt-vibevoice": "vibevoice", + "stt-qwen3-asr": "qwen3-asr", + "stt-smallest": "smallest", + "stt-smallest-stream": "smallest", + "stt-gemma4": "gemma4", + "stt-af-next": "af-next", + "stt-granite": "granite", + } + return mapping.get(stt) + + +def get_existing_stream_provider(config_yml: dict): + """Map config.yml defaults.stt_stream value back to wizard streaming provider name, or None.""" + stt_stream = config_yml.get("defaults", {}).get("stt_stream", "") + mapping = { + "stt-deepgram-stream": "deepgram", + "stt-smallest-stream": "smallest", + "stt-qwen3-asr": "qwen3-asr", + "stt-qwen3-asr-stream": "qwen3-asr", + "stt-gemma4-stream": "gemma4", + "stt-nemotron-stream": "nemotron", + } + return mapping.get(stt_stream) + + SERVICES = { - 'backend': { - 'advanced': { - 'path': 'backends/advanced', - 'cmd': ['uv', 'run', '--with-requirements', '../../setup-requirements.txt', 'python', 'init.py'], - 'description': 'Advanced AI backend with full feature set', - 'required': True + "backend": { + "advanced": { + "path": "backends/advanced", + "cmd": [ + "uv", + "run", + "--with-requirements", + "../../setup-requirements.txt", + "python", + "init.py", + ], + "description": "Advanced AI backend with full feature set", + "required": True, } }, - 'extras': { - 'speaker-recognition': { - 'path': 'extras/speaker-recognition', - 'cmd': ['uv', 'run', '--with-requirements', '../../setup-requirements.txt', 'python', 'init.py'], - 'description': 'Speaker identification and enrollment' + "extras": { + "speaker-recognition": { + "path": "extras/speaker-recognition", + "cmd": [ + "uv", + "run", + "--with-requirements", + "../../setup-requirements.txt", + "python", + "init.py", + ], + "description": "Speaker identification and enrollment", }, - 'asr-services': { - 'path': 'extras/asr-services', - 'cmd': ['uv', 'run', '--with-requirements', '../../setup-requirements.txt', 'python', 'init.py'], - 'description': 'Offline speech-to-text' + "asr-services": { + "path": "extras/asr-services", + "cmd": [ + "uv", + "run", + "--with-requirements", + "../../setup-requirements.txt", + "python", + "init.py", + ], + "description": "Offline speech-to-text", }, - 'openmemory-mcp': { - 'path': 'extras/openmemory-mcp', - 'cmd': ['./setup.sh'], - 'description': 'OpenMemory MCP server' + "langfuse": { + "path": "extras/langfuse", + "cmd": [ + "uv", + "run", + "--with-requirements", + "../../setup-requirements.txt", + "python", + "init.py", + ], + "description": "LLM observability and prompt management (local)", }, - 'langfuse': { - 'path': 'extras/langfuse', - 'cmd': ['uv', 'run', '--with-requirements', '../../setup-requirements.txt', 'python', 'init.py'], - 'description': 'LLM observability and prompt management (local)' - } - } + "llm-services": { + "path": "extras/llm-services", + "cmd": [ + "uv", + "run", + "--with-requirements", + "../../setup-requirements.txt", + "python", + "init.py", + ], + "description": "Local LLM via llama.cpp (chat + embeddings)", + }, + "wakeword-service": { + "path": "extras/wakeword-service", + "cmd": [ + "uv", + "run", + "--with-requirements", + "../../setup-requirements.txt", + "python", + "init.py", + ], + "description": "Hermes acoustic wake-word detection", + }, + "tts": { + "path": "extras/tts", + "cmd": [ + "uv", + "run", + "--with-requirements", + "../../setup-requirements.txt", + "python", + "init.py", + ], + "description": "Text-to-speech (TADA / Fish Speech / KittenTTS)", + }, + }, +} + +# Repo-root .env is the canonical store for the shared Hugging Face token: the wizard +# reads/writes it here, and each service's init.py also falls back to it. So a token +# set once (here or by hand) flows to every service that pulls models from HF. +ROOT_ENV_PATH = ".env" + +# Services whose containers pull (possibly gated) models from HuggingFace and thus +# benefit from an HF token (avoids 429 IP rate-limits, unlocks gated repos). The +# wizard prompts once if any of these is selected and passes --hf-token to each. +HF_TOKEN_SERVICES = { + "speaker-recognition", + "asr-services", + "llm-services", + "tts", + "wakeword-service", } + def discover_available_plugins(): """ Discover plugins by scanning plugins directory. @@ -75,11 +190,13 @@ def discover_available_plugins(): plugins_dir = Path("backends/advanced/src/advanced_omi_backend/plugins") if not plugins_dir.exists(): - console.print(f"[yellow]Warning: Plugins directory not found: {plugins_dir}[/yellow]") + console.print( + f"[yellow]Warning: Plugins directory not found: {plugins_dir}[/yellow]" + ) return {} discovered = {} - skip_dirs = {'__pycache__', '__init__.py', 'base.py', 'router.py'} + skip_dirs = {"__pycache__", "__init__.py", "base.py", "router.py"} for plugin_dir in plugins_dir.iterdir(): if not plugin_dir.is_dir() or plugin_dir.name in skip_dirs: @@ -89,34 +206,53 @@ def discover_available_plugins(): setup_script = plugin_dir / "setup.py" discovered[plugin_id] = { - 'has_setup': setup_script.exists(), - 'setup_path': setup_script if setup_script.exists() else None, - 'dir': plugin_dir + "has_setup": setup_script.exists(), + "setup_path": setup_script if setup_script.exists() else None, + "dir": plugin_dir, } return discovered + def check_service_exists(service_name, service_config): """Check if service directory and script exist""" - service_path = Path(service_config['path']) + service_path = Path(service_config["path"]) if not service_path.exists(): return False, f"Directory {service_path} does not exist" # For services with Python init scripts, check if init.py exists - if service_name in ['advanced', 'speaker-recognition', 'asr-services', 'langfuse']: - script_path = service_path / 'init.py' + if service_name in [ + "advanced", + "speaker-recognition", + "asr-services", + "langfuse", + "llm-services", + "wakeword-service", + "tts", + ]: + script_path = service_path / "init.py" if not script_path.exists(): return False, f"Script {script_path} does not exist" else: # For other extras, check if setup.sh exists - script_path = service_path / 'setup.sh' + script_path = service_path / "setup.sh" if not script_path.exists(): - return False, f"Script {script_path} does not exist (will be created in Phase 2)" + return ( + False, + f"Script {script_path} does not exist (will be created in Phase 2)", + ) return True, "OK" -def select_services(transcription_provider=None): + +def select_services( + transcription_provider=None, + config_yml=None, + memory_provider=None, + llm_provider=None, +): """Let user select which services to setup""" + config_yml = config_yml or {} console.print("🚀 [bold cyan]Chronicle Service Setup[/bold cyan]") console.print("Select which services to configure:\n") @@ -125,24 +261,45 @@ def select_services(transcription_provider=None): # Backend is required console.print("📱 [bold]Backend (Required):[/bold]") console.print(" ✅ Advanced Backend - Full AI features") - selected.append('advanced') + selected.append("advanced") - # Services that will be auto-added based on transcription provider choice + # Services that will be auto-added based on provider choices auto_added = set() - if transcription_provider in ("parakeet", "vibevoice", "qwen3-asr"): - auto_added.add('asr-services') + if transcription_provider in ( + "parakeet", + "vibevoice", + "qwen3-asr", + "gemma4", + "af-next", + "granite", + ): + auto_added.add("asr-services") + if llm_provider == "llamacpp": + auto_added.add("llm-services") # Optional extras console.print("\n🔧 [bold]Optional Services:[/bold]") - for service_name, service_config in SERVICES['extras'].items(): + for service_name, service_config in SERVICES["extras"].items(): # Skip services that will be auto-added based on earlier choices if service_name in auto_added: - provider_label = {"vibevoice": "VibeVoice", "parakeet": "Parakeet", "qwen3-asr": "Qwen3-ASR"}.get(transcription_provider, transcription_provider) - console.print(f" ✅ {service_config['description']} ({provider_label}) [dim](auto-selected)[/dim]") + if service_name == "llm-services": + label = "llama.cpp" + else: + label = { + "vibevoice": "VibeVoice", + "parakeet": "Parakeet", + "qwen3-asr": "Qwen3-ASR", + "gemma4": "Gemma 4", + "af-next": "Audio Flamingo Next", + "granite": "Granite Speech", + }.get(transcription_provider, transcription_provider) + console.print( + f" ✅ {service_config['description']} ({label}) [dim](auto-selected)[/dim]" + ) continue # LangFuse is handled separately via setup_langfuse_choice() - if service_name == 'langfuse': + if service_name == "langfuse": continue # Check if service exists @@ -151,11 +308,34 @@ def select_services(transcription_provider=None): console.print(f" ⏸️ {service_config['description']} - [dim]{msg}[/dim]") continue - # Speaker recognition is recommended by default - default_enable = service_name == 'speaker-recognition' + # Default to whatever was enabled last time (config.yml services map is the + # source of truth) so a re-run is press-Enter-through. Smart per-service + # heuristics below can still flip a never-configured service on. + prior_enabled = bool( + (config_yml.get("services") or {}).get(service_name, False) + ) + + # Determine smart default based on existing config + if service_name == "speaker-recognition": + # Also default True if speaker-recognition .env has a valid HF_TOKEN + speaker_env = "extras/speaker-recognition/.env" + existing_hf = read_env_value(speaker_env, "HF_TOKEN") + default_enable = prior_enabled or bool( + existing_hf + and not is_placeholder( + existing_hf, + "your_huggingface_token_here", + "your-huggingface-token-here", + "hf_xxxxx", + ) + ) + else: + default_enable = prior_enabled try: - enable_service = Confirm.ask(f" Setup {service_config['description']}?", default=default_enable) + enable_service = Confirm.ask( + f" Setup {service_config['description']}?", default=default_enable + ) except EOFError: console.print(f"Using default: {'Yes' if default_enable else 'No'}") enable_service = default_enable @@ -165,213 +345,332 @@ def select_services(transcription_provider=None): return selected -def cleanup_unselected_services(selected_services): - """Backup and remove .env files from services that weren't selected""" - - all_services = list(SERVICES['backend'].keys()) + list(SERVICES['extras'].keys()) - - for service_name in all_services: - if service_name not in selected_services: - if service_name == 'advanced': - service_path = Path(SERVICES['backend'][service_name]['path']) - else: - service_path = Path(SERVICES['extras'][service_name]['path']) - - env_file = service_path / '.env' - if env_file.exists(): - # Create backup with timestamp - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - backup_file = service_path / f'.env.backup.{timestamp}.unselected' - env_file.rename(backup_file) - console.print(f"🧹 [dim]Backed up {service_name} configuration to {backup_file.name} (service not selected)[/dim]") - -def run_service_setup(service_name, selected_services, https_enabled=False, server_ip=None, - obsidian_enabled=False, neo4j_password=None, hf_token=None, - transcription_provider='deepgram', admin_email=None, admin_password=None, - langfuse_public_key=None, langfuse_secret_key=None, langfuse_host=None, - streaming_provider=None): + +def persist_enabled_services(selected_services): + """Write the enabled-services map to config.yml — the source of truth for the + lifecycle (services.py ``--all``). + + Replaces the old approach of renaming an unselected service's ``.env`` away to + signal "disabled". Enabled/disabled is now declared explicitly in + config/config.yml ``services:``, decoupled from whether a ``.env`` exists, so a + stale or half-written ``.env`` never counts as "configured". Secrets in ``.env`` + are left untouched. + """ + # Lifecycle service names = services.py registry keys. The wizard calls the + # backend "advanced"; the lifecycle calls it "backend". + lifecycle_names = ["backend"] + list(SERVICES["extras"].keys()) + wizard_to_lifecycle = {"advanced": "backend"} + selected_lifecycle = {wizard_to_lifecycle.get(s, s) for s in selected_services} + + enabled = {name: (name in selected_lifecycle) for name in lifecycle_names} + ConfigManager().set_enabled_services(enabled) + + on = ", ".join(name for name, is_on in enabled.items() if is_on) + console.print(f"🧩 [dim]Enabled services written to config.yml: {on}[/dim]") + + +def run_service_setup( + service_name, + selected_services, + https_enabled=False, + server_ip=None, + hf_token=None, + transcription_provider="deepgram", + admin_email=None, + admin_password=None, + langfuse_public_key=None, + langfuse_secret_key=None, + langfuse_host=None, + langfuse_public_url=None, + streaming_provider=None, + llm_provider=None, + memory_provider=None, + hardware_profile=None, + live_segmentation="streaming_stt", + asr_url=None, + asr_discover=False, + llm_base_url=None, + llm_discover=False, + speaker_url=None, + speaker_discover=False, + tts_url=None, + tts_discover=False, +): """Execute individual service setup script""" - if service_name == 'advanced': - service = SERVICES['backend'][service_name] + if service_name == "advanced": + service = SERVICES["backend"][service_name] # For advanced backend, pass URLs of other selected services and HTTPS config - cmd = service['cmd'].copy() - if 'speaker-recognition' in selected_services: - cmd.extend(['--speaker-service-url', 'http://speaker-service:8085']) - if 'asr-services' in selected_services: - cmd.extend(['--parakeet-asr-url', 'http://host.docker.internal:8767']) + cmd = service["cmd"].copy() + # Speaker Recognition URL: local service → compose DNS name; otherwise honor + # the wizard's source choice (remote endpoint, or discover on the Tailnet). + if "speaker-recognition" in selected_services: + cmd.extend(["--speaker-service-url", "http://speaker-service:8085"]) + elif speaker_discover: + cmd.append("--speaker-discover") + elif speaker_url: + cmd.extend(["--speaker-service-url", speaker_url]) + + # TTS endpoint source (own/remote/Tailnet/discover). + if tts_discover: + cmd.append("--tts-discover") + elif tts_url: + cmd.extend(["--tts-url", tts_url]) + + # Legacy local-parakeet wiring — skipped when the wizard chose an ASR source + # (own/Tailnet/discover), which drives the URL via --asr-url/--asr-discover. + if "asr-services" in selected_services and not (asr_url or asr_discover): + cmd.extend(["--parakeet-asr-url", "host.docker.internal:8767"]) # Pass transcription provider choice from wizard if transcription_provider: - cmd.extend(['--transcription-provider', transcription_provider]) + cmd.extend(["--transcription-provider", transcription_provider]) + + # ASR source (where the offline provider runs): discover on the Tailnet, + # or pin an own/remote/picked URL. Overrides the local default above. + if asr_discover: + cmd.append("--asr-discover") + elif asr_url: + cmd.extend(["--asr-url", asr_url]) + + # LLM source for the Chronicle-managed llama.cpp endpoint. + if llm_discover: + cmd.append("--llm-discover") + elif llm_base_url: + cmd.extend(["--llm-base-url", llm_base_url]) # Pass streaming provider (different from batch) for re-transcription setup if streaming_provider: - cmd.extend(['--streaming-provider', streaming_provider]) + cmd.extend(["--streaming-provider", streaming_provider]) + + # Pass live-segmentation mode (windowed_batch when no streaming ASR) + if live_segmentation: + cmd.extend(["--live-segmentation", live_segmentation]) # Add HTTPS configuration if https_enabled and server_ip: - cmd.extend(['--enable-https', '--server-ip', server_ip]) - - # Always pass Neo4j password (neo4j is a required service) - if neo4j_password: - cmd.extend(['--neo4j-password', neo4j_password]) + cmd.extend(["--enable-https", "--server-ip", server_ip]) - # Add Obsidian configuration - if obsidian_enabled: - cmd.extend(['--enable-obsidian']) + # Pass LLM provider choice + if llm_provider: + cmd.extend(["--llm-provider", llm_provider]) # Pass LangFuse keys from langfuse init or external config if langfuse_public_key and langfuse_secret_key: - cmd.extend(['--langfuse-public-key', langfuse_public_key]) - cmd.extend(['--langfuse-secret-key', langfuse_secret_key]) + cmd.extend(["--langfuse-public-key", langfuse_public_key]) + cmd.extend(["--langfuse-secret-key", langfuse_secret_key]) if langfuse_host: - cmd.extend(['--langfuse-host', langfuse_host]) + cmd.extend(["--langfuse-host", langfuse_host]) + if langfuse_public_url: + cmd.extend(["--langfuse-public-url", langfuse_public_url]) else: - service = SERVICES['extras'][service_name] - cmd = service['cmd'].copy() - + service = SERVICES["extras"][service_name] + cmd = service["cmd"].copy() + + # Centralized HF token: every HuggingFace-backed service gets the same token + # (resolved once by setup_hf_token_if_needed / join_cluster) so its init.py + # writes it into that service's .env. + if service_name in HF_TOKEN_SERVICES and hf_token: + cmd.extend(["--hf-token", hf_token]) + # Add HTTPS configuration for services that support it - if service_name == 'speaker-recognition' and https_enabled and server_ip: - cmd.extend(['--enable-https', '--server-ip', server_ip]) + if service_name == "speaker-recognition" and https_enabled and server_ip: + cmd.extend(["--enable-https", "--server-ip", server_ip]) - # For speaker-recognition, pass HF_TOKEN from centralized configuration - if service_name == 'speaker-recognition': + # For speaker-recognition, pass remaining centralized configuration + if service_name == "speaker-recognition": # Define the speaker env path - speaker_env_path = 'extras/speaker-recognition/.env' + speaker_env_path = "extras/speaker-recognition/.env" - # HF Token should have been provided via setup_hf_token_if_needed() - if hf_token: - cmd.extend(['--hf-token', hf_token]) - else: - console.print("[yellow][WARNING][/yellow] No HF_TOKEN provided - speaker recognition may fail to download models") + # Pass explicit hardware profile selection when provided by wizard + if hardware_profile == "strixhalo": + cmd.extend(["--pytorch-cuda-version", "strixhalo"]) + cmd.extend(["--compute-mode", "gpu"]) + console.print( + "[blue][INFO][/blue] Using AMD Strix Halo profile for speaker recognition" + ) + + if not hf_token: + console.print( + "[yellow][WARNING][/yellow] No HF_TOKEN provided - speaker recognition may fail to download models" + ) # Pass Deepgram API key from backend if available - backend_env_path = 'backends/advanced/.env' - deepgram_key = read_env_value(backend_env_path, 'DEEPGRAM_API_KEY') - if deepgram_key and not is_placeholder(deepgram_key, 'your_deepgram_api_key_here', 'your-deepgram-api-key-here'): - cmd.extend(['--deepgram-api-key', deepgram_key]) - console.print("[blue][INFO][/blue] Found existing DEEPGRAM_API_KEY from backend config, reusing") + backend_env_path = "backends/advanced/.env" + deepgram_key = read_env_value(backend_env_path, "DEEPGRAM_API_KEY") + if deepgram_key and not is_placeholder( + deepgram_key, "your_deepgram_api_key_here", "your-deepgram-api-key-here" + ): + cmd.extend(["--deepgram-api-key", deepgram_key]) + console.print( + "[blue][INFO][/blue] Found existing DEEPGRAM_API_KEY from backend config, reusing" + ) # Pass compute mode from existing .env if available - compute_mode = read_env_value(speaker_env_path, 'COMPUTE_MODE') - if compute_mode in ['cpu', 'gpu']: - cmd.extend(['--compute-mode', compute_mode]) - console.print(f"[blue][INFO][/blue] Found existing COMPUTE_MODE ({compute_mode}), reusing") - + compute_mode = read_env_value(speaker_env_path, "COMPUTE_MODE") + if hardware_profile != "strixhalo" and compute_mode in ["cpu", "gpu"]: + cmd.extend(["--compute-mode", compute_mode]) + console.print( + f"[blue][INFO][/blue] Found existing COMPUTE_MODE ({compute_mode}), reusing" + ) + # For asr-services, pass provider from wizard's transcription choice and reuse CUDA version - if service_name == 'asr-services': + if service_name == "asr-services": # Map wizard transcription provider to asr-services provider name - wizard_to_asr_provider = { - 'vibevoice': 'vibevoice', - 'parakeet': 'nemo', - 'qwen3-asr': 'qwen3-asr', - } - asr_provider = wizard_to_asr_provider.get(transcription_provider) + if hardware_profile == "strixhalo": + wizard_to_asr_provider = { + "vibevoice": "vibevoice-strixhalo", + "parakeet": "nemo-strixhalo", + "qwen3-asr": "qwen3-asr", + "gemma4": "gemma4", + "af-next": "af-next", + } + else: + wizard_to_asr_provider = { + "vibevoice": "vibevoice", + "parakeet": "nemo", + "qwen3-asr": "qwen3-asr", + "gemma4": "gemma4", + "af-next": "af-next", + "granite": "granite", + "nemotron": "nemotron", + } + # Prefer the batch provider; fall back to the streaming provider when the + # batch one is cloud (no local container) but streaming is local — e.g. + # batch=deepgram + streaming=nemotron must still configure the nemotron + # container in asr-services. + asr_provider = wizard_to_asr_provider.get( + transcription_provider + ) or wizard_to_asr_provider.get(streaming_provider) if asr_provider: - cmd.extend(['--provider', asr_provider]) - console.print(f"[blue][INFO][/blue] Pre-selecting ASR provider: {asr_provider} (from wizard choice: {transcription_provider})") - - speaker_env_path = 'extras/speaker-recognition/.env' - cuda_version = read_env_value(speaker_env_path, 'PYTORCH_CUDA_VERSION') - if cuda_version and cuda_version in ['cu121', 'cu126', 'cu128']: - cmd.extend(['--pytorch-cuda-version', cuda_version]) - console.print(f"[blue][INFO][/blue] Found existing PYTORCH_CUDA_VERSION ({cuda_version}) from speaker-recognition, reusing") + cmd.extend(["--provider", asr_provider]) + console.print( + f"[blue][INFO][/blue] Pre-selecting ASR provider: {asr_provider}" + ) + + speaker_env_path = "extras/speaker-recognition/.env" + cuda_version = read_env_value(speaker_env_path, "PYTORCH_CUDA_VERSION") + if hardware_profile == "strixhalo": + cmd.extend(["--pytorch-cuda-version", "strixhalo"]) + console.print( + "[blue][INFO][/blue] Using AMD Strix Halo profile for ASR services" + ) + elif cuda_version and cuda_version in [ + "cu126", + "cu128", + "strixhalo", + ]: + cmd.extend(["--pytorch-cuda-version", cuda_version]) + console.print( + f"[blue][INFO][/blue] Found existing PYTORCH_CUDA_VERSION ({cuda_version}) from speaker-recognition, reusing" + ) # For langfuse, pass admin credentials from backend - if service_name == 'langfuse': + if service_name == "langfuse": if admin_email: - cmd.extend(['--admin-email', admin_email]) + cmd.extend(["--admin-email", admin_email]) if admin_password: - cmd.extend(['--admin-password', admin_password]) - - # For openmemory-mcp, try to pass OpenAI API key from backend if available - if service_name == 'openmemory-mcp': - backend_env_path = 'backends/advanced/.env' - openai_key = read_env_value(backend_env_path, 'OPENAI_API_KEY') - if openai_key and not is_placeholder(openai_key, 'your_openai_api_key_here', 'your-openai-api-key-here', 'your_openai_key_here', 'your-openai-key-here'): - cmd.extend(['--openai-api-key', openai_key]) - console.print("[blue][INFO][/blue] Found existing OPENAI_API_KEY from backend config, reusing") - + cmd.extend(["--admin-password", admin_password]) + console.print(f"\n🔧 [bold]Setting up {service_name}...[/bold]") - + # Check if service exists before running exists, msg = check_service_exists(service_name, service) if not exists: console.print(f"❌ {service_name} setup failed: {msg}") return False - + try: result = subprocess.run( - cmd, - cwd=service['path'], + cmd, + cwd=service["path"], check=True, - timeout=300 # 5 minute timeout for service setup + timeout=300, # 5 minute timeout for service setup ) - + console.print(f"✅ {service_name} setup completed") return True - + except FileNotFoundError as e: console.print(f"❌ {service_name} setup failed: {e}") - console.print(f"[yellow] Check that the service directory exists: {service['path']}[/yellow]") - console.print(f"[yellow] And that 'uv' is installed and on your PATH[/yellow]") + console.print( + f"[yellow] Check that the service directory exists: {service['path']}[/yellow]" + ) + console.print( + f"[yellow] And that 'uv' is installed and on your PATH[/yellow]" + ) return False except subprocess.TimeoutExpired as e: console.print(f"❌ {service_name} setup timed out after {e.timeout}s") console.print(f"[yellow] Configuration may be partially written.[/yellow]") console.print(f"[yellow] To retry just this service:[/yellow]") - console.print(f"[yellow] cd {service['path']} && {' '.join(service['cmd'])}[/yellow]") + console.print( + f"[yellow] cd {service['path']} && {' '.join(service['cmd'])}[/yellow]" + ) return False except subprocess.CalledProcessError as e: console.print(f"❌ {service_name} setup failed with exit code {e.returncode}") console.print(f"[yellow] Check the error output above for details.[/yellow]") console.print(f"[yellow] To retry just this service:[/yellow]") - console.print(f"[yellow] cd {service['path']} && {' '.join(service['cmd'])}[/yellow]") + console.print( + f"[yellow] cd {service['path']} && {' '.join(service['cmd'])}[/yellow]" + ) return False except Exception as e: console.print(f"❌ {service_name} setup failed: {e}") return False + def show_service_status(): """Show which services are available""" console.print("\n📋 [bold]Service Status:[/bold]") - + # Check backend - exists, msg = check_service_exists('advanced', SERVICES['backend']['advanced']) + exists, msg = check_service_exists("advanced", SERVICES["backend"]["advanced"]) status = "✅" if exists else "❌" console.print(f" {status} Advanced Backend - {msg}") - + # Check extras - for service_name, service_config in SERVICES['extras'].items(): + for service_name, service_config in SERVICES["extras"].items(): exists, msg = check_service_exists(service_name, service_config) status = "✅" if exists else "⏸️" console.print(f" {status} {service_config['description']} - {msg}") + def run_plugin_setup(plugin_id, plugin_info): """Run a plugin's setup.py script""" - setup_path = plugin_info['setup_path'] + setup_path = plugin_info["setup_path"] try: # Run plugin setup script interactively (don't capture output) # This allows the plugin to prompt for user input result = subprocess.run( - ['uv', 'run', '--with-requirements', 'setup-requirements.txt', 'python', str(setup_path)], - cwd=str(Path.cwd()) + [ + "uv", + "run", + "--with-requirements", + "setup-requirements.txt", + "python", + str(setup_path), + ], + cwd=str(Path.cwd()), ) if result.returncode == 0: console.print(f"\n[green]✅ {plugin_id} configured successfully[/green]") return True else: - console.print(f"\n[red]❌ {plugin_id} setup failed with exit code {result.returncode}[/red]") + console.print( + f"\n[red]❌ {plugin_id} setup failed with exit code {result.returncode}[/red]" + ) return False except Exception as e: console.print(f"[red]❌ Error running {plugin_id} setup: {e}[/red]") return False + def setup_plugins(): """Discover and setup plugins via delegation""" console.print("\n🔌 [bold cyan]Plugin Configuration[/bold cyan]") @@ -386,10 +685,7 @@ def setup_plugins(): # Ask about enabling community plugins try: - enable_plugins = Confirm.ask( - "Enable community plugins?", - default=True - ) + enable_plugins = Confirm.ask("Enable community plugins?", default=True) except EOFError: console.print("Using default: Yes") enable_plugins = True @@ -401,16 +697,15 @@ def setup_plugins(): # For each plugin with setup script configured_count = 0 for plugin_id, plugin_info in available_plugins.items(): - if not plugin_info['has_setup']: - console.print(f"[dim] {plugin_id}: No setup wizard available (configure manually)[/dim]") + if not plugin_info["has_setup"]: + console.print( + f"[dim] {plugin_id}: No setup wizard available (configure manually)[/dim]" + ) continue # Ask if user wants to configure this plugin try: - configure = Confirm.ask( - f" Configure {plugin_id} plugin?", - default=False - ) + configure = Confirm.ask(f" Configure {plugin_id} plugin?", default=False) except EOFError: configure = False @@ -423,38 +718,92 @@ def setup_plugins(): console.print(f"\n[green]✅ Configured {configured_count} plugin(s)[/green]") + def setup_git_hooks(): """Setup pre-commit hooks for development""" console.print("\n🔧 [bold]Setting up development environment...[/bold]") + # Check if git is available + if not shutil.which("git"): + console.print( + "⚠️ [yellow]git not found, skipping git hooks setup (optional)[/yellow]" + ) + return + try: - # Install pre-commit if not already installed - subprocess.run(['pip', 'install', 'pre-commit'], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False) + # Install pre-commit via uv tool (uv is our package manager) + subprocess.run( + ["uv", "tool", "install", "pre-commit"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) # Install git hooks - result = subprocess.run(['pre-commit', 'install', '--hook-type', 'pre-push'], - capture_output=True, - text=True) + result = subprocess.run( + ["pre-commit", "install", "--hook-type", "pre-push"], + capture_output=True, + text=True, + ) if result.returncode == 0: - console.print("✅ [green]Git hooks installed (tests will run before push)[/green]") + console.print( + "✅ [green]Git hooks installed (tests will run before push)[/green]" + ) else: console.print("⚠️ [yellow]Could not install git hooks (optional)[/yellow]") # Also install pre-commit hook - subprocess.run(['pre-commit', 'install', '--hook-type', 'pre-commit'], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False) + subprocess.run( + ["pre-commit", "install", "--hook-type", "pre-commit"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) except Exception as e: console.print(f"⚠️ [yellow]Could not setup git hooks: {e} (optional)[/yellow]") + +def _existing_hf_token(): + """Existing HF token, sourced like other shared secrets: backend .env first + (the canonical hub on a main machine), then the repo-root .env (the per-node + store for backend-less join nodes), then the legacy speaker-recognition .env. + """ + for path in ( + "backends/advanced/.env", + ROOT_ENV_PATH, + "extras/speaker-recognition/.env", + ): + value = read_env_value(path, "HF_TOKEN") + if value and not is_placeholder( + value, + "your_huggingface_token_here", + "your-huggingface-token-here", + "hf_xxxxx", + ): + return value + return None + + +def _persist_hf_token(hf_token): + """Write the resolved token to the canonical store: backend .env if it exists + (main machine), else the repo-root .env (backend-less join node). Both are + gitignored. Each service's init.py reads from the same locations. + """ + backend_env = Path("backends/advanced/.env") + target = str(backend_env) if backend_env.exists() else ROOT_ENV_PATH + Path(target).touch(mode=0o600, exist_ok=True) + set_key(target, "HF_TOKEN", hf_token, quote_mode="never") + return target + + def setup_hf_token_if_needed(selected_services): - """Prompt for Hugging Face token if needed by selected services. + """Prompt once for a shared Hugging Face token if any selected service needs it. + + Sources/stores it like other shared secrets (backend .env, falling back to the + repo-root .env for join nodes) and returns it so run_service_setup can pass it to + each service's init.py. Args: selected_services: List of service names selected by user @@ -462,76 +811,288 @@ def setup_hf_token_if_needed(selected_services): Returns: HF_TOKEN string if provided, None otherwise """ - # Check if any selected services need HF_TOKEN - needs_hf_token = 'speaker-recognition' in selected_services - - if not needs_hf_token: + needing = [s for s in selected_services if s in HF_TOKEN_SERVICES] + if not needing: return None console.print("\n🤗 [bold cyan]Hugging Face Token Configuration[/bold cyan]") - console.print("Required for speaker recognition (PyAnnote models)") - console.print("\n[blue][INFO][/blue] Get your token from: https://huggingface.co/settings/tokens") - console.print() - console.print("[yellow]⚠️ You must also accept the model agreements for these gated models:[/yellow]") - console.print(" 1. [cyan]Speaker Diarization[/cyan]") - console.print(" https://huggingface.co/pyannote/speaker-diarization-community-1") - console.print(" 2. [cyan]Segmentation Model[/cyan]") - console.print(" https://huggingface.co/pyannote/segmentation-3.0") - console.print(" 3. [cyan]Segmentation Model[/cyan]") - console.print(" https://huggingface.co/pyannote/segmentation-3.1") - console.print(" 4. [cyan]Embedding Model[/cyan]") - console.print(" https://huggingface.co/pyannote/wespeaker-voxceleb-resnet34-LM") - console.print() - console.print("[yellow]→[/yellow] Open each link and click 'Agree and access repository'") - console.print("[yellow]→[/yellow] Use the same Hugging Face account as your token") - console.print() + console.print( + "Used by HuggingFace-backed services ([cyan]" + + ", ".join(needing) + + "[/cyan]) — unlocks gated models and avoids download rate-limits." + ) + console.print( + "\n[blue][INFO][/blue] Get your token from: https://huggingface.co/settings/tokens" + ) - # Check for existing token from speaker-recognition service - speaker_env_path = 'extras/speaker-recognition/.env' - existing_token = read_env_value(speaker_env_path, 'HF_TOKEN') + # The pyannote models are gated and need explicit per-model agreement; only show + # this when speaker-recognition is among the selected services. + if "speaker-recognition" in needing: + console.print() + console.print( + "[yellow]⚠️ Speaker recognition also needs you to accept these gated model agreements:[/yellow]" + ) + console.print(" 1. [cyan]Speaker Diarization[/cyan]") + console.print( + " https://huggingface.co/pyannote/speaker-diarization-community-1" + ) + console.print(" 2. [cyan]Segmentation Model[/cyan]") + console.print(" https://huggingface.co/pyannote/segmentation-3.0") + console.print(" 3. [cyan]Segmentation Model[/cyan]") + console.print(" https://huggingface.co/pyannote/segmentation-3.1") + console.print(" 4. [cyan]Embedding Model[/cyan]") + console.print( + " https://huggingface.co/pyannote/wespeaker-voxceleb-resnet34-LM" + ) + console.print() + console.print( + "[yellow]→[/yellow] Open each link and click 'Agree and access repository'" + ) + console.print( + "[yellow]→[/yellow] Use the same Hugging Face account as your token" + ) + console.print() - # Use the masked prompt function hf_token = prompt_with_existing_masked( prompt_text="Hugging Face Token", - existing_value=existing_token, - placeholders=['your_huggingface_token_here', 'your-huggingface-token-here', 'hf_xxxxx'], + existing_value=_existing_hf_token(), + placeholders=[ + "your_huggingface_token_here", + "your-huggingface-token-here", + "hf_xxxxx", + ], is_password=True, - default="" + default="", ) if hf_token: - masked = mask_value(hf_token) - console.print(f"[green]✅ HF_TOKEN configured: {masked}[/green]\n") + target = _persist_hf_token(hf_token) + console.print( + f"[green]✅ HF_TOKEN configured: {mask_value(hf_token)}[/green] " + f"[dim](saved to {target})[/dim]\n" + ) return hf_token else: - console.print("[yellow]⚠️ No HF_TOKEN provided - speaker recognition may fail[/yellow]\n") + console.print( + "[yellow]⚠️ No HF_TOKEN provided — gated/large HuggingFace models may fail to download[/yellow]\n" + ) return None -def setup_config_file(): - """Setup config/config.yml from template if it doesn't exist""" - config_file = Path("config/config.yml") - config_template = Path("config/config.yml.template") - - if not config_file.exists(): - if config_template.exists(): - # Ensure config/ directory exists - config_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(config_template, config_file) - console.print("✅ [green]Created config/config.yml from template[/green]") - else: - console.print("⚠️ [yellow]config/config.yml.template not found, skipping config setup[/yellow]") - else: - console.print("ℹ️ [blue]config/config.yml already exists, keeping existing configuration[/blue]") # Providers that support real-time streaming -STREAMING_CAPABLE = {"deepgram", "smallest", "qwen3-asr"} +STREAMING_CAPABLE = {"deepgram", "smallest", "qwen3-asr", "gemma4", "nemotron"} + +# STT providers that can also serve as LLM (unified multimodal models) +UNIFIED_CAPABLE_STT = {"gemma4"} + + +def _scan_tailnet_services(discovery_name: str) -> list: + """Advertised instances of a chronicle-* service on the Tailnet as [{host, url}]. + + Empty when Tailscale/minidisc is unavailable or nothing is advertised. + """ + try: + found = [] + for svc in discovery.list_all_services() or []: + if svc.get("name") != discovery_name: + continue + addr, port = svc.get("address"), svc.get("port") + host = (svc.get("labels") or {}).get("host", addr) + if addr and port: + found.append({"host": host, "url": f"http://{addr}:{port}"}) + return found + except Exception: + return [] + + +def _infer_source_mode(current): + """Infer a prior source mode from an existing URL value (for press-Enter defaults). + + ``None`` = not configured before; ``""`` = was set empty (discover/later); a local + host → local; a Tailscale address → tailnet; anything else → own. + """ + if current is None: + return None + if current == "": + return "later" + low = current.lower() + if any( + h in low + for h in ( + "host.docker.internal", + "localhost", + "127.0.0.1", + "172.17.0.1", + "speaker-service", + ) + ): + return "local" + if ".ts.net" in low or any( + low.split("://")[-1].startswith(p) for p in ("100.", "fd7a:") + ): + return "tailnet" + return "own" + + +def select_service_source( + label: str, + discovery_name: str, + allow_later: bool = True, + allow_local: bool = True, + current: str = None, +): + """Ask WHERE a remote-capable service runs (hub default), returning a source dict. + + Returns one of: + {"mode": "local"} — run it on this hub (caller's default flow) + {"mode": "own", "url": ""} — an existing/external endpoint + {"mode": "tailnet", "url": ""} — pin a node advertised on the Tailnet now + {"mode": "later"} — leave unset; backend discovers it at runtime + + ``allow_local=False`` drops the on-this-hub option (used when the service was + already declined for local setup), defaulting to discover-later. ``current`` is the + previously-configured URL ('' = was discover) used to default the menu + prefill, so + a re-run is press-Enter-through. + """ + console.print(f"\n🛰️ [bold cyan]{label} — where does it run?[/bold cyan]") + # Stable choice keys regardless of which options are shown. + options: dict[str, tuple[str, str]] = {} + if allow_local: + options["1"] = ("local", "On this hub (run it here)") + options["2"] = ("own", "My own / external endpoint (enter a URL)") + options["3"] = ("tailnet", "Pick a node advertised on the Tailnet now") + if allow_later: + options["4"] = ( + "later", + "Configure from the Tailnet later (auto-discover at runtime)", + ) + for k, (_mode, desc) in options.items(): + console.print(f" {k}) {desc}") + + default_choice = "1" if allow_local else ("4" if allow_later else "2") + # Default to the previously-configured source so a re-run is press-Enter-through. + prior_mode = _infer_source_mode(current) + mode_to_key = {m: k for k, (m, _d) in options.items()} + if prior_mode in mode_to_key: + default_choice = mode_to_key[prior_mode] + console.print(f"[dim] (previously: {prior_mode})[/dim]") + + # No-op fallback when a sub-step is abandoned: prefer local, else discover-later. + _fallback = {"mode": "local"} if allow_local else {"mode": "later"} + try: + choice = Prompt.ask("Enter choice", default=default_choice) + except EOFError: + choice = default_choice + if choice not in options: + choice = default_choice + if choice == "2": + own_default = current if _infer_source_mode(current) == "own" else "" + try: + url = Prompt.ask( + f"{label} endpoint URL (e.g. http://host:8767)", default=own_default + ).strip() + except EOFError: + url = own_default + if url: + return {"mode": "own", "url": url} + console.print("[yellow]No URL entered — falling back.[/yellow]") + return _fallback + + if choice == "3": + found = _scan_tailnet_services(discovery_name) + if not found: + console.print( + f"[yellow]No '{discovery_name}' advertised on the Tailnet.[/yellow] " + + ( + "Falling back to 'configure later'." + if allow_later + else "Using fallback." + ) + ) + return {"mode": "later"} if allow_later else _fallback + console.print(f"[green]Found {len(found)} on the Tailnet:[/green]") + for i, a in enumerate(found, 1): + console.print(f" {i}) {a['host']} — {a['url']}") + # Pre-select the previously-pinned node if it's still advertised. + default_pick = "1" + if current: + base = current.rstrip("/").removesuffix("/v1") + for i, a in enumerate(found, 1): + if a["url"].rstrip("/") == base: + default_pick = str(i) + break + try: + pick = Prompt.ask("Pick one", default=default_pick) + idx = int(pick) - 1 + except (EOFError, ValueError): + idx = int(default_pick) - 1 + if 0 <= idx < len(found): + console.print(f"[green]✅[/green] Using {found[idx]['url']}") + return {"mode": "tailnet", "url": found[idx]["url"]} + return {"mode": "later"} if allow_later else _fallback + + if choice == "4" and allow_later: + console.print( + "[green]✅[/green] Will auto-discover on the Tailnet at runtime (no URL pinned)" + ) + return {"mode": "later"} + + return _fallback + + +def select_transcription_provider( + config_yml: dict = None, default_provider: str = None +): + """Ask user which transcription (batch/high-quality) provider they want. + + ``default_provider`` (the streaming provider chosen first) pre-selects the + matching batch option when that provider can also do batch — the user can still + pick a different provider for higher-quality batch transcription. + """ + config_yml = config_yml or {} + existing_provider = get_existing_stt_provider(config_yml) + + provider_to_choice = { + "deepgram": "1", + "parakeet": "2", + "vibevoice": "3", + "qwen3-asr": "4", + "smallest": "5", + "gemma4": "6", + "af-next": "7", + "granite": "8", + "none": "9", + } + choice_to_provider = {v: k for k, v in provider_to_choice.items()} + + # Prefer the streaming provider (it can also do batch), else existing config. + preferred = default_provider if default_provider in provider_to_choice else None + default_choice = provider_to_choice.get(preferred or existing_provider, "1") -def select_transcription_provider(): - """Ask user which transcription provider they want (batch/primary).""" console.print("\n🎤 [bold cyan]Transcription Provider[/bold cyan]") - console.print("Choose your speech-to-text provider (used for [bold]batch[/bold]/high-quality transcription):") - console.print("[dim]If it also supports streaming, it will be used for real-time too by default.[/dim]") + console.print( + "Choose your speech-to-text provider for [bold]batch[/bold]/high-quality transcription:" + ) + if preferred: + console.print( + f"[dim]Defaulting to {preferred} (your streaming choice — it does batch too). " + f"Pick another for a different/higher-quality batch engine.[/dim]" + ) + elif existing_provider: + provider_labels = { + "deepgram": "Deepgram", + "parakeet": "Parakeet ASR", + "vibevoice": "VibeVoice ASR", + "qwen3-asr": "Qwen3-ASR", + "smallest": "Smallest.ai Pulse", + "gemma4": "Gemma 4", + "af-next": "Audio Flamingo Next", + "granite": "Granite Speech", + } + console.print( + f"[blue][INFO][/blue] Current: {provider_labels.get(existing_provider, existing_provider)}" + ) console.print() choices = { @@ -540,96 +1101,156 @@ def select_transcription_provider(): "3": "VibeVoice ASR (offline, batch only, built-in diarization, GPU)", "4": "Qwen3-ASR (offline, streaming + batch, 52 languages, GPU)", "5": "Smallest.ai Pulse (cloud, streaming + batch)", - "6": "None (skip transcription setup)" + "6": "Gemma 4 (offline, streaming + batch, prompt-based diarization, MTP, GPU)", + "7": "Audio Flamingo Next (offline, batch, timestamped diarization, GPU; noncommercial license)", + "8": "IBM Granite Speech (offline, batch, LLM-backbone, en/fr/de/es/pt, GPU)", + "9": "None (skip transcription setup)", } for key, desc in choices.items(): - console.print(f" {key}) {desc}") + marker = " [dim](default)[/dim]" if key == default_choice else "" + console.print(f" {key}) {desc}{marker}") console.print() while True: try: - choice = Prompt.ask("Enter choice", default="1") + choice = Prompt.ask("Enter choice", default=default_choice) if choice in choices: - if choice == "1": - return "deepgram" - elif choice == "2": - return "parakeet" - elif choice == "3": - return "vibevoice" - elif choice == "4": - return "qwen3-asr" - elif choice == "5": - return "smallest" - elif choice == "6": - return "none" - console.print(f"[red]Invalid choice. Please select from {list(choices.keys())}[/red]") + return choice_to_provider[choice] + console.print( + f"[red]Invalid choice. Please select from {list(choices.keys())}[/red]" + ) except EOFError: - console.print("Using default: Deepgram") - return "deepgram" + console.print(f"Using default: {choices.get(default_choice, 'Deepgram')}") + return choice_to_provider.get(default_choice, "deepgram") -def select_streaming_provider(batch_provider): - """Ask if user wants a different provider for real-time streaming. +def select_streaming_provider(config_yml: dict = None): + """Ask which real-time streaming provider to use (or skip). - If the batch provider supports streaming, offer to use the same (saves a step). - If it's batch-only, the user must pick a streaming provider or skip. + Asked BEFORE the batch provider so a streaming-capable choice can default the + batch selection — the common case is one provider doing both, but the user can + still pick a different (e.g. higher-quality) batch engine next. Returns: - Streaming provider name if different from batch, or None (same / skipped). + Streaming provider name, or None if streaming is skipped. """ - if batch_provider in ("none", None): - return None - - if batch_provider in STREAMING_CAPABLE: - # Batch provider can already stream — just confirm - console.print(f"\n🔊 [bold cyan]Streaming[/bold cyan]") - console.print(f"{batch_provider} supports both batch and streaming.") - try: - use_different = Confirm.ask("Use a different provider for real-time streaming?", default=False) - except EOFError: - return None - if not use_different: - return None - else: - # Batch-only provider — need to pick a streaming provider - console.print(f"\n🔊 [bold cyan]Streaming[/bold cyan]") - console.print(f"{batch_provider} is batch-only. Pick a streaming provider for real-time transcription:") + config_yml = config_yml or {} + existing_stream = get_existing_stream_provider(config_yml) - # Show streaming-capable providers (excluding the batch provider) - streaming_choices = {} - provider_map = {} - idx = 1 + console.print("\n🔊 [bold cyan]Real-time Streaming Transcription[/bold cyan]") + console.print( + "Choose a provider for [bold]real-time[/bold] (live) transcription. " + "You'll pick the batch/high-quality provider next." + ) + console.print( + "[dim]A provider that also does batch will be offered as the batch default too.[/dim]" + ) + console.print() - for name, desc in [ + options = [ ("deepgram", "Deepgram (cloud, streaming)"), ("smallest", "Smallest.ai Pulse (cloud, streaming)"), - ("qwen3-asr", "Qwen3-ASR (offline, streaming)"), - ]: - if name != batch_provider: - streaming_choices[str(idx)] = desc - provider_map[str(idx)] = name - idx += 1 - - skip_key = str(idx) + ("qwen3-asr", "Qwen3-ASR (offline, streaming, GPU)"), + ( + "gemma4", + "Gemma 4 (offline, streaming-ish, prompt-based diarization, MTP, GPU)", + ), + ( + "nemotron", + "Nemotron 3.5 (offline, true cache-aware streaming ~100ms, GPU)", + ), + ] + streaming_choices = {} + provider_map = {} + for idx, (name, desc) in enumerate(options, start=1): + streaming_choices[str(idx)] = desc + provider_map[str(idx)] = name + skip_key = str(len(options) + 1) streaming_choices[skip_key] = "Skip (no real-time streaming)" provider_map[skip_key] = None + # Default to the previously-configured streaming provider, else skip. + default_stream_choice = skip_key + for k, v in provider_map.items(): + if v and v == existing_stream: + default_stream_choice = k + break + for key, desc in streaming_choices.items(): - console.print(f" {key}) {desc}") + marker = " [dim](current)[/dim]" if key == default_stream_choice else "" + console.print(f" {key}) {desc}{marker}") console.print() while True: try: - choice = Prompt.ask("Enter choice", default="1") + choice = Prompt.ask("Enter choice", default=default_stream_choice) if choice in streaming_choices: result = provider_map[choice] if result: - console.print(f"[green]✅[/green] Streaming: {result}, Batch: {batch_provider}") + console.print(f"[green]✅[/green] Streaming: {result}") + else: + console.print("[blue][INFO][/blue] No real-time streaming") return result - console.print(f"[red]Invalid choice. Please select from {list(streaming_choices.keys())}[/red]") + console.print( + f"[red]Invalid choice. Please select from {list(streaming_choices.keys())}[/red]" + ) except EOFError: - return None + return provider_map.get(default_stream_choice) + + +def select_live_segmentation(batch_provider): + """When there's no streaming ASR, offer windowed-batch live transcription. + + Without a streaming ASR, a continuously-streaming source is only transcribed when + it disconnects (24h+ for always-on sources). Windowed batch transcribes fixed + ~30s windows so conversations are created incrementally as audio streams in. + + Returns: + "windowed_batch" or "streaming_stt". + """ + console.print( + "\n🪟 [bold cyan]Live transcription without streaming ASR[/bold cyan]" + ) + console.print( + f"{batch_provider} is batch-only and you skipped streaming. Without live " + "transcription, a continuously-streaming source is only transcribed when it " + "disconnects." + ) + try: + enable = Confirm.ask( + "Enable windowed batch transcription (transcribe ~every 30s as audio streams in)?", + default=True, + ) + except EOFError: + return "streaming_stt" + + if enable: + console.print("[green]✅[/green] Live segmentation: windowed_batch") + return "windowed_batch" + return "streaming_stt" + + +def derive_langfuse_public_url(langfuse_mode, langfuse_external, server_ip): + """Derive the browser-accessible LangFuse URL used for dashboard deep-links. + + This becomes ``observability.langfuse.public_url`` in config.yml, which the + backend serves to the web UI for Langfuse trace/session links. + + - external mode: the host the user entered is already browser-accessible. + - local mode: the bundled instance is exposed on plain HTTP port 3002 + (see extras/langfuse/docker-compose.yml). Use the Tailscale name/IP chosen + for HTTPS when available, otherwise fall back to detected Tailscale info, + otherwise localhost. + """ + if langfuse_mode == "external": + return langfuse_external.get("host") + + host = server_ip + if not host: + ts_dns, ts_ip = detect_tailscale_info() + host = ts_dns or ts_ip or "localhost" + return f"http://{host}:3002" def setup_langfuse_choice(): @@ -648,31 +1269,36 @@ def setup_langfuse_choice(): console.print() try: - has_existing = Confirm.ask("Use an existing external LangFuse instance instead of local?", default=False) + has_existing = Confirm.ask( + "Use an existing external LangFuse instance instead of local?", + default=False, + ) except EOFError: console.print("Using default: No (will set up locally)") has_existing = False if not has_existing: # Check if the local langfuse directory exists - exists, msg = check_service_exists('langfuse', SERVICES['extras']['langfuse']) + exists, msg = check_service_exists("langfuse", SERVICES["extras"]["langfuse"]) if exists: console.print("[green]✅[/green] Will set up local LangFuse instance") - return 'local', {} + return "local", {} else: console.print(f"[yellow]⚠️ Local LangFuse not available: {msg}[/yellow]") - console.print("[yellow] Will proceed without LangFuse — add it later when available[/yellow]") - return 'local', {} + console.print( + "[yellow] Will proceed without LangFuse — add it later when available[/yellow]" + ) + return "local", {} # External LangFuse — collect connection details console.print() console.print("[bold]Enter your external LangFuse connection details:[/bold]") - backend_env_path = 'backends/advanced/.env' + backend_env_path = "backends/advanced/.env" - existing_host = read_env_value(backend_env_path, 'LANGFUSE_HOST') + existing_host = read_env_value(backend_env_path, "LANGFUSE_HOST") # Don't treat the local docker host as an existing external value - if existing_host and 'langfuse-web' in existing_host: + if existing_host and "langfuse-web" in existing_host: existing_host = None host = prompt_with_existing_masked( @@ -680,48 +1306,552 @@ def setup_langfuse_choice(): existing_value=existing_host, placeholders=[""], is_password=False, - default="https://cloud.langfuse.com" + default="https://cloud.langfuse.com", ) - existing_pub = read_env_value(backend_env_path, 'LANGFUSE_PUBLIC_KEY') + existing_pub = read_env_value(backend_env_path, "LANGFUSE_PUBLIC_KEY") public_key = prompt_with_existing_masked( prompt_text="LangFuse public key", existing_value=existing_pub, placeholders=[""], is_password=False, - default="" + default="", ) - existing_sec = read_env_value(backend_env_path, 'LANGFUSE_SECRET_KEY') + existing_sec = read_env_value(backend_env_path, "LANGFUSE_SECRET_KEY") secret_key = prompt_with_existing_masked( prompt_text="LangFuse secret key", existing_value=existing_sec, placeholders=[""], is_password=True, - default="" + default="", ) if not (host and public_key and secret_key): - console.print("[yellow]⚠️ Incomplete LangFuse configuration — skipping[/yellow]") + console.print( + "[yellow]⚠️ Incomplete LangFuse configuration — skipping[/yellow]" + ) return None, {} console.print(f"[green]✅[/green] External LangFuse configured: {host}") - return 'external', { - 'host': host, - 'public_key': public_key, - 'secret_key': secret_key, + return "external", { + "host": host, + "public_key": public_key, + "secret_key": secret_key, } +def select_hardware_profile( + selected_services, transcription_provider, streaming_provider +): + """Select hardware profile for GPU-backed optional services. + + Returns: + "strixhalo" for AMD Strix Halo profile, otherwise None. + """ + strix_capable_providers = {"parakeet", "vibevoice"} + needs_hardware_choice = ( + "speaker-recognition" in selected_services + or transcription_provider in strix_capable_providers + or streaming_provider in strix_capable_providers + ) + + if not needs_hardware_choice: + return None + + console.print("\n🧠 [bold cyan]Hardware Profile[/bold cyan]") + console.print( + "Choose target hardware for GPU services (speaker recognition and offline ASR):" + ) + choices = { + "1": "Standard (CPU/NVIDIA CUDA)", + "2": "AMD Strix Halo (ROCm, gfx1151 / Ryzen AI Max)", + } + for key, desc in choices.items(): + console.print(f" {key}) {desc}") + console.print() + + while True: + try: + choice = Prompt.ask("Enter choice", default="1") + if choice == "1": + return None + if choice == "2": + console.print( + "[green]✅[/green] Using AMD Strix Halo profile where supported" + ) + return "strixhalo" + console.print( + f"[red]Invalid choice. Please select from {list(choices.keys())}[/red]" + ) + except EOFError: + return None + + +def select_llm_provider( + config_yml: dict = None, transcription_provider: str = None +) -> str: + """Ask user which LLM provider to use for memory extraction. + + Uses Langfuse-style flow: "Do you have your own LLM?" → Yes: custom URL → No: pick managed option. + When transcription_provider is a unified-capable model (e.g. Gemma 4), offers to reuse + it for LLM tasks too. + + Returns: + "openai", "ollama", "llamacpp", "gemma4-unified", or "none" + """ + config_yml = config_yml or {} + existing_llm = config_yml.get("defaults", {}).get("llm", "") + existing_is_custom = existing_llm in ("custom-llm",) + existing_is_unified = existing_llm == "gemma4-llm" + + console.print("\n🤖 [bold cyan]LLM Provider[/bold cyan]") + console.print( + "Choose your language model provider for memory extraction and analysis:" + ) + console.print() + + # If the STT provider is a unified-capable model, offer to reuse it for LLM + if transcription_provider in UNIFIED_CAPABLE_STT: + provider_labels = {"gemma4": "Gemma 4"} + label = provider_labels.get(transcription_provider, transcription_provider) + console.print( + f"[green]💡[/green] {label} is a multimodal model that can also handle LLM tasks " + "(memory extraction, chat, summaries)." + ) + console.print( + f"[dim]This reuses the same model already loaded for STT — no extra GPU memory needed.[/dim]" + ) + default_unified = existing_is_unified or True + try: + use_unified = Confirm.ask( + f"Use {label} for both STT and LLM?", + default=default_unified, + ) + except EOFError: + use_unified = default_unified + if use_unified: + console.print( + f"[green]✅[/green] {label} will handle both STT and LLM (unified mode)" + ) + return "gemma4-unified" + console.print(f"[dim]OK, choosing a separate LLM provider instead.[/dim]") + console.print() + + # Step 1: Do you have your own LLM endpoint? + try: + has_own = Confirm.ask( + "Do you have your own OpenAI-compatible LLM endpoint?", + default=existing_is_custom, + ) + except EOFError: + has_own = existing_is_custom + + if has_own: + # User has their own endpoint — this maps to the existing "custom" flow in init.py + console.print( + "[green]✅[/green] Will configure custom LLM endpoint in backend setup" + ) + return "custom" + + # Step 2: Pick from managed options + llm_to_choice = { + "openai-llm": "1", + "local-llm": "2", + "llamacpp-llm": "3", + } + default_choice = llm_to_choice.get(existing_llm, "1") + + choices = { + "1": "OpenAI (GPT-4o-mini, requires API key)", + "2": "Ollama (local models, runs on your machine)", + "3": "llama.cpp (Chronicle-managed, local GGUF models, GPU recommended)", + "4": "None (skip memory extraction)", + } + + for key, desc in choices.items(): + marker = " [dim](current)[/dim]" if key == default_choice else "" + console.print(f" {key}) {desc}{marker}") + console.print() + + while True: + try: + choice = Prompt.ask("Enter choice", default=default_choice) + if choice in choices: + return {"1": "openai", "2": "ollama", "3": "llamacpp", "4": "none"}[ + choice + ] + console.print( + f"[red]Invalid choice. Please select from {list(choices.keys())}[/red]" + ) + except EOFError: + console.print(f"Using default: {choices.get(default_choice, 'OpenAI')}") + return {"1": "openai", "2": "ollama", "3": "llamacpp", "4": "none"}.get( + default_choice, "openai" + ) + + +def maybe_install_agent_services(): + """Offer to install the boot-persistence systemd user services. + + Installs two units (with linger): the native node agent (:8775 — WebUI control + + Tailnet advertising), which runs on the host and so doesn't survive a reboot + on its own; and a oneshot that runs ``start --all`` on boot to bring the + container stack back. The latter matters under rootless Podman, which — unlike + Docker — has no daemon to re-apply ``restart:`` policies after a reboot. + """ + console.print("\n🔁 [bold cyan]Auto-start on boot (Optional)[/bold cyan]") + console.print( + "Installs systemd user services so both the node agent (:8775) and your" + ) + console.print( + "container stack come back after a reboot. (Rootless Podman, unlike Docker," + ) + console.print( + "has no daemon to revive containers on boot, so this is needed there.)" + ) + + if not services._systemd_user_available(): + services._print_systemd_unavailable_help() + return + + try: + install = Confirm.ask( + "Install it as a systemd user service so it auto-starts on boot?", + default=True, + ) + except EOFError: + console.print("Using default: Yes") + install = True + + if install: + services.install_systemd_agents() + + +def maybe_enable_remote_control(): + """Offer to run a Claude remote-control session so you can start Claude Code + sessions on this machine from the Claude mobile app. + + Off by default: this launches `claude remote-control` (in tmux) and, if you + accept, installs it as a systemd user service so it survives reboots. Requires + the claude CLI (logged in) and tmux on the host. + """ + console.print("\n📱 [bold cyan]Claude Code from your phone (Optional)[/bold cyan]") + console.print( + "Run a `claude remote-control` server on this host so you can spawn new" + ) + console.print( + "Claude Code sessions from the Claude mobile app (Code tab). It runs in tmux" + ) + console.print( + "and can auto-start on boot. Toggle it any time from the WebUI System page." + ) + + if shutil.which("claude") is None: + console.print( + "[dim]claude CLI not found — skipping. Install Claude Code and log in, " + "then run: services.py remote-control install[/dim]" + ) + return + if shutil.which("tmux") is None: + console.print("[dim]tmux not found — skipping (install tmux first).[/dim]") + return + + try: + enable = Confirm.ask( + "Enable Claude remote-control (start new sessions from your phone)?", + default=False, + ) + except EOFError: + console.print("Using default: No") + enable = False + + if not enable: + return + + if services._systemd_user_available(): + services.install_remote_control() + else: + # No systemd user instance (e.g. WSL without systemd=true) — start it now + # in tmux; it won't survive a reboot. + services._print_systemd_unavailable_help() + services.start_remote_control() + + +# Services that make sense to run on a service-only node joining a cluster +# (the compute-heavy / GPU ones the backend reaches over the Tailnet). +JOINABLE_SERVICES = { + "asr-services": "Offline speech-to-text (ASR) — GPU", + "speaker-recognition": "Speaker identification — GPU", + "tts": "Text-to-speech — GPU", + "llm-services": "Local LLM via llama.cpp — GPU", + "wakeword-service": "Acoustic wake-word detection", +} + + +def select_setup_type(): + """Ask whether this machine is the main hub or is joining an existing cluster. + + Returns ``"join"`` for a service-only node that contributes a service to an + existing backend, else ``"main"`` (the normal full single-machine / hub setup). + Defaults to ``"main"`` so re-running the wizard on the hub is unchanged. + """ + console.print("\n🏗️ [bold cyan]Setup type[/bold cyan]") + console.print( + " 1) Main machine — run the Chronicle backend here (single machine or cluster hub)" + ) + console.print( + " 2) Join a cluster — this machine only runs a service (e.g. GPU ASR) and" + ) + console.print( + " advertises it to an existing backend on your Tailnet (no backend here)" + ) + console.print() + choice = Prompt.ask("Enter choice", default="1") + return "join" if choice.strip() == "2" else "main" + + +def join_cluster(): + """Configure THIS machine as a service-only node joining an existing cluster. + + Discovers the hub (backend) on the Tailnet, lets you pick which service(s) this + box provides, runs their init wizards, starts them, and runs the node agent so + they advertise on the Tailnet — the hub discovers and uses them automatically. + This box does NOT run the backend. + """ + console.print("\n🔗 [bold cyan]Join an existing Chronicle cluster[/bold cyan]") + console.print( + "This machine will run one or more services (e.g. GPU ASR) and advertise them on\n" + "your Tailnet. Your main Chronicle backend then discovers and uses them.\n" + ) + + # 0. Tailscale prereq — discovery AND advertising both need it. Check up front so + # a missing/down Tailscale gives the real cause, not a misleading "no backend found". + if shutil.which("tailscale") is None: + console.print( + "[red]✗ Tailscale isn't installed.[/red] A join node finds the hub and advertises\n" + " its services over your Tailnet. Install it (https://tailscale.com/download),\n" + " run [cyan]sudo tailscale up[/cyan], then re-run this wizard." + ) + return + try: + ts_connected = ( + subprocess.run(["tailscale", "status"], capture_output=True).returncode == 0 + ) + except OSError: + ts_connected = False + if not ts_connected: + console.print( + "[red]✗ Tailscale is installed but not connected.[/red] Run " + "[cyan]sudo tailscale up[/cyan]\n (approve this device on your tailnet), then re-run." + ) + if not Confirm.ask( + "Continue anyway? (discovery/advertising won't work — you'd wire service URLs manually)", + default=False, + ): + return + + # 0b. Tailscale running now ≠ Tailscale after a reboot. If the unit is started but + # not enabled, it silently won't come back on boot and the node drops off the + # Tailnet (services unreachable) until someone notices. Offer to make it stick. + if tailscaled_enabled_at_boot() is False: + console.print( + "[yellow]⚠️ Tailscale is running but not enabled to start on boot.[/yellow]\n" + " After a reboot this node would silently drop off the Tailnet (services\n" + " unreachable) until you start it again manually." + ) + if Confirm.ask( + "Enable tailscaled to start on boot now? (sudo systemctl enable --now tailscaled)", + default=True, + ): + if enable_tailscaled_at_boot(): + console.print( + "[green]✅[/green] tailscaled enabled — it'll survive reboots now." + ) + else: + console.print( + "[red]✗ Couldn't enable it.[/red] Run it yourself: " + "[cyan]sudo systemctl enable --now tailscaled[/cyan]" + ) + + # 1. Discover the hub + what's already advertised in the cluster. + console.print("🔍 Looking for your Chronicle backend on the Tailnet…") + backend_url = discovery.discover_service(discovery.CHRONICLE_BACKEND) + claimed = {s.get("name") for s in discovery.list_all_services()} + if backend_url: + console.print(f"[green]✅[/green] Found backend at [cyan]{backend_url}[/cyan]") + else: + console.print( + "[yellow]⚠️ No backend discovered on the Tailnet.[/yellow] Make sure your main\n" + " machine is running with Tailscale and this box is on the same Tailnet." + ) + if not Confirm.ask("Continue anyway?", default=True): + return + if claimed: + console.print("\n[dim]Already advertised on the Tailnet:[/dim]") + for name in sorted(n for n in claimed if n): + console.print(f" [dim]• {name}[/dim]") + + # 2. Pick the service(s) this node will provide. + disc_names = services._DISCOVERY_NAMES # lifecycle name → chronicle-* name + console.print("\n📦 [bold]Which service(s) will THIS machine provide?[/bold]") + keys = list(JOINABLE_SERVICES) + for i, svc in enumerate(keys, 1): + taken = disc_names.get(svc) in claimed + tag = ( + " [yellow](already in cluster — a 2nd one is usually unnecessary)[/yellow]" + if taken + else "" + ) + console.print(f" {i}) {svc} — {JOINABLE_SERVICES[svc]}{tag}") + raw = Prompt.ask("Enter number(s), comma-separated", default="1") + chosen: list[str] = [] + for part in raw.split(","): + part = part.strip() + if part.isdigit() and 1 <= int(part) <= len(keys): + svc = keys[int(part) - 1] + if svc not in chosen: + chosen.append(svc) + if not chosen: + console.print("[red]No valid services selected. Aborting.[/red]") + return + console.print(f"[green]✅[/green] This node will provide: {', '.join(chosen)}") + + # 3. Hardware profile (e.g. Strix Halo) for GPU services. + hardware_profile = select_hardware_profile(chosen, None, None) + + # 4. Enable ONLY these services in config.yml — a join node runs no backend. + persist_enabled_services(chosen) + + # 4b. A join node has no backend .env, so the shared HF token lives in the repo-root + # .env here. Prompt once (if any chosen service needs it) and pass it through. + hf_token = setup_hf_token_if_needed(chosen) + + # 5. Configure each chosen service (runs its init.py interactively). + for svc in chosen: + run_service_setup( + svc, chosen, hf_token=hf_token, hardware_profile=hardware_profile + ) + + # 6. Start the service(s) + the node agent (which advertises on the Tailnet). + # build=True because images won't exist yet on a fresh node. + console.print("\n🚀 Starting services + node agent…") + services.start_services(chosen, build=True) + + # 7. Offer boot persistence for the node agent (systemd user service). + maybe_install_agent_services() + + # 8. Next steps + the one wiring gotcha. + console.print("\n🎉 [bold green]This node has joined the cluster![/bold green]") + console.print( + " • It's advertising on your Tailnet — it'll appear on the backend's Network page." + ) + console.print( + " • [yellow]Wiring note:[/yellow] if your backend pins the service URL to " + "host.docker.internal/localhost" + ) + console.print( + " (e.g. PARAKEET_ASR_URL), clear it or point it at this box's Tailscale name so the" + ) + console.print( + " backend uses THIS node; otherwise minidisc discovery wires it automatically." + ) + + +def check_container_engine() -> bool: + """Container-engine prereq — Chronicle runs everything in containers. + + Resolves the configured engine (docker default, or podman via config.yml / + CONTAINER_ENGINE) and verifies both the engine binary and its compose front-end + are installed and the runtime is reachable. Mirrors the Tailscale prereq style: + on any problem we explain it and let the user continue at their own risk, since + there is no container-less install path. + + Returns True to proceed, False to abort the wizard. + """ + engine = services.container_engine() + compose = ( + services.compose_base() + ) # e.g. ['docker', 'compose'] or ['podman-compose'] + + if shutil.which(engine) is None: + console.print( + f"[red]✗ {engine} isn't installed.[/red] Chronicle runs every service in " + "containers —\n" + " there is no install path without a container engine. Install " + f"[cyan]{engine}[/cyan]\n" + " (https://docs.docker.com/engine/install/ or https://podman.io/docs/installation),\n" + " then re-run this wizard." + ) + return Confirm.ask( + "Continue anyway? (nothing will start until a container engine is installed)", + default=False, + ) + + # compose front-end: docker ships it as a plugin (`docker compose`), podman as a + # separate `podman-compose` binary — check whichever the resolved command needs. + compose_bin = compose[0] + if shutil.which(compose_bin) is None: + hint = ( + "It ships with Docker Desktop / the docker-compose-plugin package." + if compose_bin == "docker" + else "Install it with [cyan]pip install podman-compose[/cyan] (or your package manager)." + ) + console.print( + f"[red]✗ {' '.join(compose)} isn't available.[/red] Chronicle uses it to " + "build and run\n" + f" the service stack. {hint}" + ) + return Confirm.ask("Continue anyway?", default=False) + + # Runtime liveness: ` info` exits non-zero if the daemon/runtime is down + # (e.g. Docker Desktop not started, dockerd not running). + try: + up = ( + subprocess.run([engine, "info"], capture_output=True, timeout=20).returncode + == 0 + ) + except (OSError, subprocess.TimeoutExpired): + up = False + if not up: + console.print( + f"[red]✗ {engine} is installed but the runtime isn't reachable.[/red] " + f"Start it\n" + " (e.g. launch Docker Desktop, or run [cyan]sudo systemctl start docker[/cyan]) " + "and re-run." + ) + return Confirm.ask( + "Continue anyway? (services won't start until the engine is running)", + default=False, + ) + + console.print( + f"[green]✅[/green] Container engine: [cyan]{engine}[/cyan] " + f"(compose: [cyan]{' '.join(compose)}[/cyan])" + ) + return True + + def main(): """Main orchestration logic""" console.print("🎉 [bold green]Welcome to Chronicle![/bold green]\n") console.print("[dim]This wizard is safe to run as many times as you like.[/dim]") - console.print("[dim]It backs up your existing config and preserves previously entered values.[/dim]") - console.print("[dim]When unsure, just press Enter — the defaults will work.[/dim]\n") + console.print( + "[dim]It backs up your existing config and preserves previously entered values.[/dim]" + ) + console.print( + "[dim]When unsure, just press Enter — the defaults will work.[/dim]\n" + ) + + # Ensure config.yml exists (create from template if needed) — also resolves which + # container engine (docker/podman) the rest of the wizard will use. + config_mgr = ConfigManager() + config_mgr.ensure_config_yml() - # Setup config file from template - setup_config_file() + # Container-engine prereq — everything below runs in containers, so bail early + # (with a clear reason) rather than failing deep in a build/start step. + if not check_container_engine(): + return # Setup git hooks first setup_git_hooks() @@ -729,22 +1859,191 @@ def main(): # Show what's available show_service_status() - # Ask about transcription provider FIRST (determines which services are needed) - transcription_provider = select_transcription_provider() + # Read existing config.yml once — used as defaults for ALL wizard questions below + config_yml = config_mgr.get_full_config() - # Ask about streaming provider (if batch provider doesn't stream, or user wants a different one) - streaming_provider = select_streaming_provider(transcription_provider) + # Fork: a service-only node joining an existing cluster takes a separate, much + # shorter path (no backend / LLM / memory setup here) and returns. + if select_setup_type() == "join": + join_cluster() + return + + # Ask about the real-time STREAMING provider FIRST. + streaming_provider = select_streaming_provider(config_yml) + + # Then the batch/high-quality provider, defaulting to the streaming provider + # when it can also do batch (one provider for both is the simple common case, + # but the user can still choose a different/higher-quality batch engine). + transcription_provider = select_transcription_provider( + config_yml, default_provider=streaming_provider + ) + + # If batch and streaming are the same provider there is no separate streaming + # engine to wire — setup_transcription sets both defaults.stt and stt_stream. + # Only pass streaming_provider to init.py when it actually differs from batch. + if streaming_provider == transcription_provider: + streaming_provider = None + console.print( + f"[green]✅[/green] Using {transcription_provider} for both batch and streaming" + ) + elif streaming_provider: + console.print( + f"[blue][INFO][/blue] Batch: {transcription_provider}, " + f"streaming: {streaming_provider} (batch-retranscribe enabled)" + ) + + # No streaming ASR (batch-only provider + streaming skipped) → offer windowed batch + live_segmentation = "streaming_stt" + if ( + transcription_provider not in ("none", None) + and transcription_provider not in STREAMING_CAPABLE + and streaming_provider is None + ): + live_segmentation = select_live_segmentation(transcription_provider) + + # LLM Provider selection (asked once here, passed to init.py — avoids double-ask) + llm_provider = select_llm_provider(config_yml, transcription_provider) - # Service Selection (pass transcription_provider so we skip asking about ASR when already chosen) - selected_services = select_services(transcription_provider) + # Chronicle's agentic Markdown vault is the only memory provider — no choice. + memory_provider = "chronicle" - # Auto-add asr-services if any local ASR was chosen (batch or streaming) - local_asr_providers = ("parakeet", "vibevoice", "qwen3-asr") - needs_asr = transcription_provider in local_asr_providers or (streaming_provider and streaming_provider in local_asr_providers) - if needs_asr and 'asr-services' not in selected_services: - reason = transcription_provider if transcription_provider in local_asr_providers else streaming_provider - console.print(f"[blue][INFO][/blue] Auto-adding ASR services for {reason} transcription") - selected_services.append('asr-services') + # Service Selection (pass provider choices so we skip asking about auto-added services) + selected_services = select_services( + transcription_provider, config_yml, memory_provider, llm_provider + ) + + # ── Service source: where each remote-capable compute service runs ────────── + # Hub flow defaults to "on this hub", but offers own/external endpoint, pinning a + # Tailnet-advertised node, or deferring to runtime discovery. A *remote* ASR/LLM + # choice (own/Tailnet/later) also suppresses auto-adding the local service below. + backend_env = "backends/advanced/.env" + OFFLINE_DISCOVERABLE_ASR = {"parakeet", "qwen3-asr", "gemma4", "af-next"} + _ASR_ENV_KEY = { + "parakeet": "PARAKEET_ASR_URL", + "qwen3-asr": "QWEN3_ASR_URL", + "gemma4": "GEMMA4_ASR_URL", + "af-next": "AF_NEXT_ASR_URL", + } + asr_url, asr_discover, asr_remote = None, False, False + if transcription_provider in OFFLINE_DISCOVERABLE_ASR: + asr_current = read_env_value(backend_env, _ASR_ENV_KEY[transcription_provider]) + src = select_service_source("ASR", "chronicle-asr", current=asr_current) + asr_remote = src["mode"] != "local" + if src["mode"] == "local": + asr_url = "http://host.docker.internal:8767" + elif src["mode"] in ("own", "tailnet"): + asr_url = src["url"] + elif src["mode"] == "later": + asr_discover = True + + llm_base_url, llm_discover, llm_remote = None, False, False + if llm_provider == "llamacpp": + llm_current = read_env_value(backend_env, "LLM_BASE_URL") + src = select_service_source( + "Local LLM (llama.cpp)", "chronicle-llm", current=llm_current + ) + llm_remote = src["mode"] != "local" + if src["mode"] == "local": + llm_base_url = "http://host.docker.internal:8083/v1" + elif src["mode"] in ("own", "tailnet"): + # Discovered/picked URLs are bare host:port → ensure the OpenAI /v1 path. + u = src["url"].rstrip("/") + llm_base_url = u if u.endswith("/v1") else u + "/v1" + elif src["mode"] == "later": + llm_discover = True + + # Speaker Recognition + TTS: when NOT run locally, optionally point the backend at + # a remote/own endpoint or let it auto-discover one on the Tailnet. + speaker_url, speaker_discover = None, False + if "speaker-recognition" not in selected_services: + speaker_current = read_env_value(backend_env, "SPEAKER_SERVICE_URL") + prior_remote = _infer_source_mode(speaker_current) in ( + "own", + "tailnet", + "later", + ) + try: + if Confirm.ask( + "Use a remote / external Speaker Recognition service?", + default=prior_remote, + ): + src = select_service_source( + "Speaker Recognition", + "chronicle-speaker", + allow_local=False, + current=speaker_current, + ) + if src["mode"] in ("own", "tailnet"): + speaker_url = src["url"] + else: + speaker_discover = True + except EOFError: + pass + + tts_url, tts_discover = None, False + if "tts" in selected_services: + # Running TTS locally on the host — pin the local endpoint (the compose no + # longer defaults CHRONICLE_TTS_URL, so an unset value would mean 'discover'). + tts_url = "http://host.docker.internal:8770" + else: + tts_current = read_env_value(backend_env, "CHRONICLE_TTS_URL") + prior_set = _infer_source_mode(tts_current) in ("own", "tailnet", "later") + try: + if Confirm.ask( + "Configure a Text-to-Speech (TTS) endpoint?", default=prior_set + ): + src = select_service_source( + "Text-to-Speech", + "chronicle-tts", + allow_local=False, + current=tts_current, + ) + if src["mode"] in ("own", "tailnet"): + tts_url = src["url"] + else: + tts_discover = True + except EOFError: + pass + + # Auto-add asr-services if a LOCAL ASR was chosen (not a remote/discover source) + local_asr_providers = ( + "parakeet", + "vibevoice", + "qwen3-asr", + "gemma4", + "af-next", + "granite", + "nemotron", + ) + needs_asr = not asr_remote and ( + transcription_provider in local_asr_providers + or (streaming_provider and streaming_provider in local_asr_providers) + ) + if needs_asr and "asr-services" not in selected_services: + reason = ( + transcription_provider + if transcription_provider in local_asr_providers + else streaming_provider + ) + console.print( + f"[blue][INFO][/blue] Auto-adding ASR services for {reason} transcription" + ) + selected_services.append("asr-services") + + # Auto-add llm-services if llama.cpp runs LOCALLY (not a remote/discover source) + if ( + llm_provider == "llamacpp" + and not llm_remote + and "llm-services" not in selected_services + ): + exists, _ = check_service_exists( + "llm-services", SERVICES["extras"]["llm-services"] + ) + if exists: + console.print( + "[blue][INFO][/blue] LLM provider is llama.cpp — auto-adding llm-services" + ) + selected_services.append("llm-services") if not selected_services: console.print("\n[yellow]No services selected. Exiting.[/yellow]") @@ -752,53 +2051,82 @@ def main(): # LangFuse Configuration (before service setup so keys can be passed to backend) langfuse_mode, langfuse_external = setup_langfuse_choice() - if langfuse_mode == 'local' and 'langfuse' not in selected_services: - selected_services.append('langfuse') + if langfuse_mode == "local" and "langfuse" not in selected_services: + selected_services.append("langfuse") # HF Token Configuration (if services require it) + hardware_profile = select_hardware_profile( + selected_services, transcription_provider, streaming_provider + ) + hf_token = setup_hf_token_if_needed(selected_services) # HTTPS Configuration (for services that need it) https_enabled = False server_ip = None - + # Check if we have services that benefit from HTTPS - https_services = {'advanced', 'speaker-recognition'} # advanced will always need https then + https_services = { + "advanced", + "speaker-recognition", + } # advanced will always need https then needs_https = bool(https_services.intersection(selected_services)) - + if needs_https: console.print("\n🔒 [bold cyan]HTTPS Configuration[/bold cyan]") - console.print("HTTPS enables microphone access in browsers and secure connections") + console.print( + "HTTPS enables microphone access in browsers and secure connections" + ) + + # Default to existing HTTPS_ENABLED setting + existing_https = read_env_value("backends/advanced/.env", "HTTPS_ENABLED") + default_https = existing_https == "true" try: - https_enabled = Confirm.ask("Enable HTTPS for selected services?", default=False) + https_enabled = Confirm.ask( + "Enable HTTPS for selected services?", default=default_https + ) except EOFError: - console.print("Using default: No") - https_enabled = False + console.print(f"Using default: {'Yes' if default_https else 'No'}") + https_enabled = default_https if https_enabled: # Try to auto-detect Tailscale address ts_dns, ts_ip = detect_tailscale_info() if ts_dns: - console.print(f"\n[green][AUTO-DETECTED][/green] Tailscale DNS: {ts_dns}") + console.print( + f"\n[green][AUTO-DETECTED][/green] Tailscale DNS: {ts_dns}" + ) if ts_ip: - console.print(f"[green][AUTO-DETECTED][/green] Tailscale IP: {ts_ip}") + console.print( + f"[green][AUTO-DETECTED][/green] Tailscale IP: {ts_ip}" + ) + console.print( + "[green][AUTO-DETECTED][/green] Minidisc service discovery enabled — " + "cross-machine services will find each other automatically" + ) default_address = ts_dns elif ts_ip: console.print(f"\n[green][AUTO-DETECTED][/green] Tailscale IP: {ts_ip}") + console.print( + "[green][AUTO-DETECTED][/green] Minidisc service discovery enabled — " + "cross-machine services will find each other automatically" + ) default_address = ts_ip else: console.print("\n[blue][INFO][/blue] Tailscale not detected") - console.print("[blue][INFO][/blue] To find your Tailscale address: tailscale status --json | jq -r '.Self.DNSName'") + console.print( + "[blue][INFO][/blue] To find your Tailscale address: tailscale status --json | jq -r '.Self.DNSName'" + ) default_address = None console.print("[blue][INFO][/blue] For local-only access, use 'localhost'") console.print("Examples: localhost, myhost.tail1234.ts.net, 100.64.1.2") # Check for existing SERVER_IP from backend .env - backend_env_path = 'backends/advanced/.env' - existing_ip = read_env_value(backend_env_path, 'SERVER_IP') + backend_env_path = "backends/advanced/.env" + existing_ip = read_env_value(backend_env_path, "SERVER_IP") # Use existing value, or auto-detected address, or localhost as default effective_default = default_address or "localhost" @@ -806,92 +2134,153 @@ def main(): server_ip = prompt_with_existing_masked( prompt_text="Server IP/Domain for SSL certificates", existing_value=existing_ip, - placeholders=['localhost', 'your-server-ip-here'], + placeholders=["localhost", "your-server-ip-here"], is_password=False, - default=effective_default + default=effective_default, ) console.print(f"[green]✅[/green] HTTPS configured for: {server_ip}") - # Neo4j Configuration (always required - used by Knowledge Graph) - neo4j_password = None - obsidian_enabled = False - - if 'advanced' in selected_services: - console.print("\n🗄️ [bold cyan]Neo4j Configuration[/bold cyan]") - console.print("Neo4j is used for Knowledge Graph (entity/relationship extraction from conversations)") - console.print() - - # Always prompt for Neo4j password (masked input) - try: - console.print("Neo4j password (min 8 chars) [leave empty for default: neo4jpassword]") - neo4j_password = prompt_password("Neo4j password", min_length=8) - except (EOFError, KeyboardInterrupt): - neo4j_password = "neo4jpassword" - console.print("Using default password") - if not neo4j_password: - neo4j_password = "neo4jpassword" - - console.print("[green]✅[/green] Neo4j configured") - - # Obsidian is optional (graph-based knowledge management for vault notes) - console.print("\n🗂️ [bold cyan]Obsidian Integration (Optional)[/bold cyan]") - console.print("Enable graph-based knowledge management for Obsidian vault notes") - console.print() - - try: - obsidian_enabled = Confirm.ask("Enable Obsidian integration?", default=False) - except EOFError: - console.print("Using default: No") - obsidian_enabled = False - - if obsidian_enabled: - console.print("[green]✅[/green] Obsidian integration will be configured") + # Decide how the TLS cert is managed. The per-service init scripts derive + # the same mode (from server_ip + tailscaled socket) and render their + # Caddyfile/compose to match, so nothing needs to be threaded through here. + cert_mode = decide_cert_mode(server_ip) + if cert_mode == "static": + # *.ts.net with no mountable tailscaled socket (e.g. Docker Desktop on + # macOS): issue the cert on the host now. The services.py startup hook + # renews it on restart — no cron needed. + console.print( + "\n[blue][INFO][/blue] Generating host-issued TLS certificate..." + ) + if generate_tailscale_certs("certs"): + console.print( + f"[green]✅[/green] Tailscale cert generated in certs/ for {server_ip}" + ) + else: + console.print( + "[yellow]⚠️ Certificate generation failed; it will be retried " + "automatically on the next service start.[/yellow]" + ) + else: + # Caddy obtains and auto-renews the cert itself: *.ts.net via the mounted + # tailscaled socket, a real domain via Let's Encrypt, IP/localhost via + # Caddy's internal CA. No host cert file, no renewal cron. + console.print( + f"\n[green]✅[/green] Caddy will obtain and auto-renew the TLS " + f"certificate for {server_ip} (no host cert file, no renewal cron)" + ) + console.print( + "[blue][INFO][/blue] Trusted automatically for *.ts.net and real " + "domains; IP/localhost get a self-signed cert you accept in the browser." + ) + + # If this box is served over a Tailscale address, both reachability and + # (Caddy-managed) cert renewal depend on tailscaled being up. Started-but- + # not-enabled means it silently won't come back after a reboot — offer to + # make it stick, same as the join-node path. + served_over_tailscale = server_ip.endswith(".ts.net") or ( + bool(ts_ip) and server_ip == ts_ip + ) + if served_over_tailscale and tailscaled_enabled_at_boot() is False: + console.print( + "\n[yellow]⚠️ Tailscale is running but not enabled to start on boot.[/yellow]\n" + " After a reboot this box would silently drop off the Tailnet —\n" + " the dashboard/API would be unreachable and the TLS cert wouldn't renew." + ) + if Confirm.ask( + "Enable tailscaled to start on boot now? (sudo systemctl enable --now tailscaled)", + default=True, + ): + if enable_tailscaled_at_boot(): + console.print( + "[green]✅[/green] tailscaled enabled — it'll survive reboots now." + ) + else: + console.print( + "[red]✗ Couldn't enable it.[/red] Run it yourself: " + "[cyan]sudo systemctl enable --now tailscaled[/cyan]" + ) # Pure Delegation - Run Each Service Setup console.print(f"\n📋 [bold]Setting up {len(selected_services)} services...[/bold]") - # Clean up .env files from unselected services (creates backups) - cleanup_unselected_services(selected_services) + # Record which services are enabled (config.yml is the lifecycle source of truth) + persist_enabled_services(selected_services) success_count = 0 failed_services = [] # Pre-populate langfuse keys from external config (if user chose external mode) - langfuse_public_key = langfuse_external.get('public_key') - langfuse_secret_key = langfuse_external.get('secret_key') - langfuse_host = langfuse_external.get('host') # None for local (backend defaults to langfuse-web) + langfuse_public_key = langfuse_external.get("public_key") + langfuse_secret_key = langfuse_external.get("secret_key") + langfuse_host = langfuse_external.get( + "host" + ) # None for local (backend defaults to langfuse-web) + + # Browser-accessible URL for Langfuse dashboard deep-links (stored in config.yml). + # Derived from server_ip/Tailscale so links don't hardcode localhost. + langfuse_public_url = derive_langfuse_public_url( + langfuse_mode, langfuse_external, server_ip + ) # Determine setup order: langfuse first (to get API keys), then backend (with langfuse keys), then others setup_order = [] - if 'langfuse' in selected_services: - setup_order.append('langfuse') - if 'advanced' in selected_services: - setup_order.append('advanced') + if "langfuse" in selected_services: + setup_order.append("langfuse") + if "advanced" in selected_services: + setup_order.append("advanced") for service in selected_services: if service not in setup_order: setup_order.append(service) # Read admin credentials from existing backend .env (for langfuse init reuse) - backend_env_path = 'backends/advanced/.env' - wizard_admin_email = read_env_value(backend_env_path, 'ADMIN_EMAIL') - wizard_admin_password = read_env_value(backend_env_path, 'ADMIN_PASSWORD') + backend_env_path = "backends/advanced/.env" + wizard_admin_email = read_env_value(backend_env_path, "ADMIN_EMAIL") + wizard_admin_password = read_env_value(backend_env_path, "ADMIN_PASSWORD") for service in setup_order: - if run_service_setup(service, selected_services, https_enabled, server_ip, - obsidian_enabled, neo4j_password, hf_token, transcription_provider, - admin_email=wizard_admin_email, admin_password=wizard_admin_password, - langfuse_public_key=langfuse_public_key, langfuse_secret_key=langfuse_secret_key, - langfuse_host=langfuse_host, streaming_provider=streaming_provider): + if run_service_setup( + service, + selected_services, + https_enabled, + server_ip, + hf_token, + transcription_provider, + admin_email=wizard_admin_email, + admin_password=wizard_admin_password, + langfuse_public_key=langfuse_public_key, + langfuse_secret_key=langfuse_secret_key, + langfuse_host=langfuse_host, + langfuse_public_url=langfuse_public_url, + streaming_provider=streaming_provider, + llm_provider=llm_provider, + memory_provider=memory_provider, + hardware_profile=hardware_profile, + live_segmentation=live_segmentation, + asr_url=asr_url, + asr_discover=asr_discover, + llm_base_url=llm_base_url, + llm_discover=llm_discover, + speaker_url=speaker_url, + speaker_discover=speaker_discover, + tts_url=tts_url, + tts_discover=tts_discover, + ): success_count += 1 # After local langfuse setup, read generated API keys for backend - if service == 'langfuse': - langfuse_env_path = 'extras/langfuse/.env' - langfuse_public_key = read_env_value(langfuse_env_path, 'LANGFUSE_INIT_PROJECT_PUBLIC_KEY') - langfuse_secret_key = read_env_value(langfuse_env_path, 'LANGFUSE_INIT_PROJECT_SECRET_KEY') + if service == "langfuse": + langfuse_env_path = "extras/langfuse/.env" + langfuse_public_key = read_env_value( + langfuse_env_path, "LANGFUSE_INIT_PROJECT_PUBLIC_KEY" + ) + langfuse_secret_key = read_env_value( + langfuse_env_path, "LANGFUSE_INIT_PROJECT_SECRET_KEY" + ) if langfuse_public_key and langfuse_secret_key: - console.print("[blue][INFO][/blue] LangFuse API keys will be passed to backend configuration") + console.print( + "[blue][INFO][/blue] LangFuse API keys will be passed to backend configuration" + ) else: failed_services.append(service) @@ -900,13 +2289,23 @@ def main(): # without the backend init overwriting them setup_plugins() + # Optional: install the native host agents (service manager + discovery) as + # systemd user services so they auto-start on boot like the containers do. + if "advanced" in selected_services: + maybe_install_agent_services() + # Optional (off by default): a Claude remote-control session so you can + # start Claude Code sessions on this host from the Claude mobile app. + maybe_enable_remote_control() + # Final Summary console.print(f"\n🎊 [bold green]Setup Complete![/bold green]") - console.print(f"✅ {success_count}/{len(selected_services)} services configured successfully") + console.print( + f"✅ {success_count}/{len(selected_services)} services configured successfully" + ) if failed_services: console.print(f"❌ Failed services: {', '.join(failed_services)}") - + # Next Steps console.print("\n📖 [bold]Next Steps:[/bold]") @@ -914,68 +2313,104 @@ def main(): console.print("") console.print("📝 [bold cyan]Configuration Files Updated:[/bold cyan]") console.print(" • [green].env files[/green] - API keys and service URLs") - console.print(" • [green]config.yml[/green] - Model definitions and memory provider settings") + console.print( + " • [green]config.yml[/green] - Model definitions and memory provider settings" + ) console.print("") # Development Environment Setup console.print("1. Setup development environment (git hooks, testing):") console.print(" [cyan]make setup-dev[/cyan]") - console.print(" [dim]This installs pre-commit hooks to run tests before pushing[/dim]") + console.print( + " [dim]This installs pre-commit hooks to run tests before pushing[/dim]" + ) console.print("") # Service Management Commands console.print("2. Start all configured services:") console.print(" [cyan]./start.sh[/cyan]") - console.print(" [dim]Or: uv run --with-requirements setup-requirements.txt python services.py start --all --build[/dim]") + console.print( + " [dim]Or: uv run --with-requirements setup-requirements.txt python services.py start --all --build[/dim]" + ) console.print("") console.print("3. Or start individual services:") - + configured_services = [] - if 'advanced' in selected_services and 'advanced' not in failed_services: + if "advanced" in selected_services and "advanced" not in failed_services: configured_services.append("backend") - if 'speaker-recognition' in selected_services and 'speaker-recognition' not in failed_services: - configured_services.append("speaker-recognition") - if 'asr-services' in selected_services and 'asr-services' not in failed_services: + if ( + "speaker-recognition" in selected_services + and "speaker-recognition" not in failed_services + ): + configured_services.append("speaker-recognition") + if "asr-services" in selected_services and "asr-services" not in failed_services: configured_services.append("asr-services") - if 'openmemory-mcp' in selected_services and 'openmemory-mcp' not in failed_services: - configured_services.append("openmemory-mcp") - if 'langfuse' in selected_services and 'langfuse' not in failed_services: + if "langfuse" in selected_services and "langfuse" not in failed_services: configured_services.append("langfuse") # LangFuse prompt management info - if langfuse_mode == 'local' and 'langfuse' not in failed_services: + if langfuse_mode == "local" and "langfuse" not in failed_services: console.print("") - console.print("[bold cyan]Prompt Management:[/bold cyan] Once services are running, edit AI prompts at:") - if https_enabled and server_ip: - console.print(f" [link=https://{server_ip}:3443/project/chronicle/prompts]https://{server_ip}:3443/project/chronicle/prompts[/link]") - else: - console.print(" [link=http://localhost:3002/project/chronicle/prompts]http://localhost:3002/project/chronicle/prompts[/link]") - elif langfuse_mode == 'external' and langfuse_host: + console.print( + "[bold cyan]Prompt Management:[/bold cyan] Once services are running, edit AI prompts at:" + ) + prompts_url = f"{langfuse_public_url.rstrip('/')}/project/chronicle/prompts" + console.print(f" [link={prompts_url}]{prompts_url}[/link]") + elif langfuse_mode == "external" and langfuse_host: console.print("") - console.print(f"[bold cyan]Prompt Management:[/bold cyan] Edit AI prompts at your LangFuse instance:") + console.print( + f"[bold cyan]Prompt Management:[/bold cyan] Edit AI prompts at your LangFuse instance:" + ) console.print(f" {langfuse_host}") if configured_services: service_list = " ".join(configured_services) - console.print(f" [cyan]uv run --with-requirements setup-requirements.txt python services.py start {service_list}[/cyan]") - + console.print( + f" [cyan]uv run --with-requirements setup-requirements.txt python services.py start {service_list}[/cyan]" + ) + console.print("") console.print("3. Check service status:") console.print(" [cyan]./status.sh[/cyan]") - console.print(" [dim]Or: uv run --with-requirements setup-requirements.txt python services.py status[/dim]") + console.print( + " [dim]Or: uv run --with-requirements setup-requirements.txt python services.py status[/dim]" + ) console.print("") console.print("4. Stop services when done:") console.print(" [cyan]./stop.sh[/cyan]") - console.print(" [dim]Or: uv run --with-requirements setup-requirements.txt python services.py stop --all[/dim]") - + console.print( + " [dim]Or: uv run --with-requirements setup-requirements.txt python services.py stop --all[/dim]" + ) + + # Show minidisc discovery info if Tailscale is available + ts_dns_final, ts_ip_final = detect_tailscale_info() + if ts_dns_final or ts_ip_final: + console.print("") + console.print( + "🔍 [bold cyan]Distributed Setup:[/bold cyan] Minidisc service discovery is active" + ) + console.print( + " Services on other Tailnet machines (HAVPE relay, ASR, etc.) will" + ) + console.print( + " auto-discover this backend — no manual URL configuration needed" + ) + console.print(f"\n🚀 [bold]Enjoy Chronicle![/bold]") - + # Show individual service usage console.print(f"\n💡 [dim]Tip: You can also setup services individually:[/dim]") - console.print(f"[dim] cd backends/advanced && uv run --with-requirements ../../setup-requirements.txt python init.py[/dim]") - console.print(f"[dim] cd extras/speaker-recognition && uv run --with-requirements ../../setup-requirements.txt python init.py[/dim]") - console.print(f"[dim] cd extras/asr-services && uv run --with-requirements ../../setup-requirements.txt python init.py[/dim]") + console.print( + f"[dim] cd backends/advanced && uv run --with-requirements ../../setup-requirements.txt python init.py[/dim]" + ) + console.print( + f"[dim] cd extras/speaker-recognition && uv run --with-requirements ../../setup-requirements.txt python init.py[/dim]" + ) + console.print( + f"[dim] cd extras/asr-services && uv run --with-requirements ../../setup-requirements.txt python init.py[/dim]" + ) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/wizard.sh b/wizard.sh index 02942349..fb749554 100755 --- a/wizard.sh +++ b/wizard.sh @@ -1 +1,3 @@ +#!/bin/bash +source "$(dirname "$0")/scripts/check_uv.sh" uv run --with-requirements setup-requirements.txt wizard.py