diff --git a/.cargo/config.toml b/.cargo/config.toml index 6961cb2e..2a87ed10 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,6 +3,11 @@ # Moved from src-tauri/.cargo/config.toml to the workspace root so that all # workspace members share the same build configuration. # +# RLX (optional): Cargo resolves `rlx` via ../rlx/rlx (workspace.dependencies). +# CI — .github/actions/checkout-rlx clones https://github.com/MIT-RLX/rlx.git +# Local — `npm run setup:rlx` or direnv (.envrc) symlinks ../rlx -> RLX_ROOT +# (default /Users/Shared/rlx; override in gitignored rlx.path). +# # `relative = true` paths are resolved relative to this file's parent # directory (the project root). @@ -79,6 +84,17 @@ target-dir = "src-tauri/target" # On non-Windows platforms cmake ignores this variable. CMAKE_MSVC_RUNTIME_LIBRARY = "MultiThreadedDLL" +# ── macOS: cmake / sccache paths ────────────────────────────────────────────── +# Homebrew cmake/sccache are not on the default PATH when cargo invokes build +# scripts. The cmake-0.1.x crate respects the CMAKE env var as the binary path. +# +# These are NOT set here because cargo's [env] table is unconditional: a value +# like "/opt/homebrew/bin/cmake" leaks to Windows / Linux runners where the +# path doesn't exist, causing cmake-rs to panic with "is `cmake` not installed?" +# (os error 3 / ENOENT). Instead they are exported only on macOS by: +# - .envrc (for `cargo build` via direnv) +# - scripts/tauri-build.js (for `npm run tauri:build`) + # ── macOS deployment target ─────────────────────────────────────────────────── # # 14.0 satisfies both: diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..237b787f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Heavy artifacts that should never be sent to the Docker build context. +# Without this, `docker build` for Dockerfile.upgrade-test transfers tens of +# GB of local target/ output and chokes on I/O. + +# Rust build artifacts +target/ +src-tauri/target/ +crates/*/target/ +**/*.rlib + +# Node / frontend +node_modules/ +.svelte-kit/ +build/ +.vite/ + +# IDE / OS +.git/ +.idea/ +.vscode/ +.DS_Store + +# Logs / test results +test-results/ +playwright-report/ +logs/ +*.log +*.log.zst + +# Local data +.skill/ +data/ +fixtures/ + +# Cargo registry should come from the volume mount, not the context +.cargo/ diff --git a/.envrc b/.envrc index 3e62ae0e..daddf491 100644 --- a/.envrc +++ b/.envrc @@ -18,6 +18,18 @@ if [ "$(uname)" = "Darwin" ]; then if [ -x "$GAR" ]; then export AR="$GAR" fi + + # Homebrew cmake/sccache are not on the default PATH when cargo invokes + # build scripts. The cmake-0.1.x crate respects CMAKE as the binary path. + # Kept out of .cargo/config.toml because [env] is unconditional and would + # leak these macOS paths to Windows / Linux runners (cmake-rs panics with + # "is `cmake` not installed?" / os error 3). + if [ -z "${CMAKE:-}" ] && [ -x "/opt/homebrew/bin/cmake" ]; then + export CMAKE="/opt/homebrew/bin/cmake" + fi + if [ -z "${SCCACHE_PATH:-}" ] && [ -x "/opt/homebrew/bin/sccache" ]; then + export SCCACHE_PATH="/opt/homebrew/bin/sccache" + fi fi # Use prebuilt llama.cpp if available (skip cmake rebuild). @@ -25,3 +37,9 @@ fi if [ -d ".llama-prebuilt/lib" ]; then export LLAMA_PREBUILT_DIR="$(pwd)/.llama-prebuilt" fi + +# Sibling RLX checkout for optional llm-rlx / text-embeddings-rlx features. +# Default: symlink ../rlx -> /Users/Shared/rlx (override via rlx.path or RLX_ROOT). +if [ -f "scripts/ensure-rlx.sh" ]; then + bash scripts/ensure-rlx.sh +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 11a89f19..21f6e494 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -30,8 +30,22 @@ fi # Run cargo fmt on staged Rust files if echo "$STAGED_FILES" | grep -q '\.rs$'; then echo "🦀 Running cargo fmt…" - cargo fmt - git add $( echo "$STAGED_FILES" | grep '\.rs$' ) + # GUI git clients (editors, Tower, etc.) launch hooks in a non-interactive + # shell that doesn't source ~/.zshrc, so rustup's ~/.cargo/bin is missing + # from PATH. Source the env file rustup ships, or fall back to the default + # install path. + if [ -f "$HOME/.cargo/env" ]; then + . "$HOME/.cargo/env" + elif [ -d "$HOME/.cargo/bin" ]; then + export PATH="$HOME/.cargo/bin:$PATH" + fi + if ! command -v cargo >/dev/null 2>&1; then + echo "⚠️ cargo not found on PATH; skipping cargo fmt." >&2 + echo " Install rustup (https://rustup.rs) or add ~/.cargo/bin to your shell's PATH." >&2 + else + cargo fmt + git add $( echo "$STAGED_FILES" | grep '\.rs$' ) + fi fi echo "✅ Pre-commit checks passed (basic validation only)." diff --git a/.github/actions/checkout-rlx/action.yml b/.github/actions/checkout-rlx/action.yml new file mode 100644 index 00000000..9be3884b --- /dev/null +++ b/.github/actions/checkout-rlx/action.yml @@ -0,0 +1,21 @@ +name: Checkout RLX +description: >- + Clone https://github.com/MIT-RLX/rlx next to the skill repo so path + dependencies (../../../rlx/rlx) resolve in CI. + +inputs: + ref: + description: Branch, tag, or SHA to checkout + required: false + default: main + +runs: + using: composite + steps: + - name: Clone MIT-RLX/rlx + shell: bash + env: + RLX_REF: ${{ inputs.ref }} + RLX_URL: https://github.com/MIT-RLX/rlx.git + GITHUB_ACTIONS: "true" + run: bash scripts/ensure-rlx.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 95fe4fbe..9f2e1eab 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,7 +30,7 @@ updates: patterns: - "*" ignore: - # wgpu cannot be upgraded independently: burn-wgpu, gpu-fft, zuna-rs, - # and luna-rs all require wgpu 26.x. Upgrade the whole Burn/GPU stack together. + # wgpu cannot be upgraded independently: zuna-rs and luna-rs require wgpu 26.x. + # Upgrade wgpu together with the encoder crates. - dependency-name: "wgpu" versions: [">=27"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d02c7d62..2d6657f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,9 @@ jobs: - name: Fetch skills submodule run: git submodule update --init --depth=1 skills + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + - name: Setup Rust bootstrap (Linux) uses: ./.github/actions/setup-rust-bootstrap-linux @@ -113,12 +116,6 @@ jobs: cache-targets: false cache-on-failure: false - # ── ONNX Runtime (official binaries) ─────────────────────────────────── - # Avoid ort-sys download flakiness from cdn.pyke.io in CI by installing - # the official Microsoft release and pointing ORT_LIB_LOCATION at it. - - name: Setup ONNX Runtime (Linux x64) - uses: ./.github/actions/setup-onnxruntime-linux - - name: Setup Vulkan SDK repository uses: ./.github/actions/setup-vulkan-linux @@ -134,7 +131,7 @@ jobs: cmake binutils mold clang protobuf-compiler libopenblas-dev vulkan-sdk - version: tauri-rust-check-v6 + version: tauri-rust-check-v7 - name: Download prebuilt llama libs (Linux, optional) run: node scripts/ci.mjs download-llama linux x86_64-unknown-linux-gnu vulkan @@ -150,7 +147,10 @@ jobs: set -euo pipefail run_cmd() { cargo clippy --locked --target x86_64-unknown-linux-gnu \ - --workspace --exclude skill \ + --workspace --exclude skill --exclude skill-router \ + -- -D warnings + cargo clippy --locked --target x86_64-unknown-linux-gnu \ + --no-default-features -p skill-router --features cpu \ -- -D warnings } if ! run_cmd; then @@ -169,7 +169,7 @@ jobs: RUSTC_WRAPPER: sccache CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=mold - # Cache cmake-based -sys crate builds (llama-cpp-sys, etc.) + # Cache cmake-based -sys crate builds (espeak-ng-sys, etc.) CMAKE_C_COMPILER_LAUNCHER: sccache CMAKE_CXX_COMPILER_LAUNCHER: sccache shell: bash @@ -245,6 +245,18 @@ jobs: echo "::notice::No integration-testable crates affected" fi + if echo "$CRATE_FLAGS" | grep -qw "\-p skill-router"; then + echo "test_router=true" >> "$GITHUB_OUTPUT" + else + echo "test_router=false" >> "$GITHUB_OUTPUT" + fi + + ROUTERLESS_FLAGS="$(echo "$CRATE_FLAGS" | sed 's/-p skill-router//g' | xargs || true)" + echo "routerless_flags=$ROUTERLESS_FLAGS" >> "$GITHUB_OUTPUT" + + ROUTERLESS_INT="$(echo "$INT_FLAGS" | sed 's/-p skill-router//g' | xargs || true)" + echo "routerless_int_flags=$ROUTERLESS_INT" >> "$GITHUB_OUTPUT" + - name: cargo test (affected crates — unit) id: rust_unit_tests if: steps.changed.outputs.skip != 'true' @@ -257,15 +269,28 @@ jobs: set -euo pipefail start_ts="$(date +%s)" run_cmd() { + local flags="$1" + [[ -z "$flags" ]] && return 0 cargo test --locked --target x86_64-unknown-linux-gnu \ - ${{ steps.changed.outputs.crate_flags }} \ + $flags \ --lib } - if ! run_cmd; then + run_router_cmd() { + cargo test --locked --target x86_64-unknown-linux-gnu \ + --no-default-features -p skill-router --features cpu \ + --lib + } + run_all() { + run_cmd "${{ steps.changed.outputs.routerless_flags }}" + if [[ "${{ steps.changed.outputs.test_router }}" == "true" ]]; then + run_router_cmd + fi + } + if ! run_all; then if [[ -n "${LLAMA_PREBUILT_DIR:-}" ]]; then echo "::warning::unit tests failed with prebuilt llama; retrying with source build" unset LLAMA_PREBUILT_DIR LLAMA_PREBUILT_SHARED - run_cmd + run_all else exit 1 fi @@ -285,15 +310,28 @@ jobs: set -euo pipefail start_ts="$(date +%s)" run_cmd() { + local flags="$1" + [[ -z "$flags" ]] && return 0 cargo test --locked --target x86_64-unknown-linux-gnu \ - ${{ steps.changed.outputs.int_flags }} \ + $flags \ --test '*' } - if ! run_cmd; then + run_router_cmd() { + cargo test --locked --target x86_64-unknown-linux-gnu \ + --no-default-features -p skill-router --features cpu \ + --test '*' + } + run_all() { + run_cmd "${{ steps.changed.outputs.routerless_int_flags }}" + if [[ "${{ steps.changed.outputs.test_router }}" == "true" ]]; then + run_router_cmd + fi + } + if ! run_all; then if [[ -n "${LLAMA_PREBUILT_DIR:-}" ]]; then echo "::warning::integration tests failed with prebuilt llama; retrying with source build" unset LLAMA_PREBUILT_DIR LLAMA_PREBUILT_SHARED - run_cmd + run_all else exit 1 fi @@ -470,13 +508,16 @@ jobs: with: fetch-depth: 1 + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + - name: Validate Windows app manifest shell: bash run: node scripts/check-windows-manifest.mjs src-tauri/manifest.xml # Puts cl.exe, link.exe, lib.exe, and signtool.exe in PATH. # Required for the espeak-ng static library build (lib.exe) and for - # the Vulkan SDK CMake integration used by the llm-vulkan feature. + # the Vulkan SDK CMake integration used by wgpu/rlx-gpu features. - name: Set up MSVC developer environment uses: ilammy/msvc-dev-cmd@v1 @@ -534,11 +575,6 @@ jobs: cache-targets: false cache-on-failure: false - # ── ONNX Runtime (official binaries) ─────────────────────────────────── - # Avoid ort-sys download flakiness from cdn.pyke.io in CI. - - name: Setup ONNX Runtime (Windows x64) - uses: ./.github/actions/setup-onnxruntime-windows - # ── Build cache (sccache) ─────────────────────────────────────────────── - name: Setup sccache uses: Mozilla-Actions/sccache-action@v0.0.9 @@ -610,6 +646,10 @@ jobs: # only happens once per change to the install script. On cache hit the # install script detects the SDK via filesystem and skips the download. - name: Cache Vulkan SDK + # Don't fail the job on transient cache-service errors (actions/cache@v5 + # occasionally returns "failure" instead of "miss"). The install script + # below handles a missing SDK by downloading it on the spot. + continue-on-error: true uses: actions/cache@v5 with: path: C:\VulkanSDK @@ -638,7 +678,7 @@ jobs: # ── Standalone LLVM 19 for bindgen ────────────────────────────────────── # VS2022's bundled Clang 19 is an MSVC-compat build (clang-cl) whose # mmintrin.h references __builtin_ia32_* GCC-style intrinsics that - # clang-cl doesn't implement, causing bindgen (llama-cpp-sys-4) to panic + # clang-cl doesn't implement, causing bindgen to panic # with ~20 "use of undeclared identifier '__builtin_ia32_…'" errors. # # Fix: standalone LLVM 19 (non-MSVC-compat) implements all those builtins @@ -742,6 +782,9 @@ jobs: with: fetch-depth: 1 + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + - name: Setup Rust bootstrap (Linux) uses: ./.github/actions/setup-rust-bootstrap-linux @@ -757,9 +800,6 @@ jobs: cache-targets: false cache-on-failure: false - - name: Setup ONNX Runtime (Linux x64) - uses: ./.github/actions/setup-onnxruntime-linux - - name: Setup Vulkan SDK repository uses: ./.github/actions/setup-vulkan-linux @@ -774,7 +814,7 @@ jobs: libpipewire-0.3-dev mold clang protobuf-compiler libopenblas-dev vulkan-sdk - version: llm-e2e-v5 + version: llm-e2e-v6 - name: Cache HuggingFace model uses: actions/cache@v5 @@ -787,7 +827,7 @@ jobs: RUSTC_WRAPPER: sccache CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=mold - # Cache cmake-based -sys crate builds (llama-cpp-sys, etc.) + # Cache cmake-based -sys crate builds (espeak-ng-sys, etc.) CMAKE_C_COMPILER_LAUNCHER: sccache CMAKE_CXX_COMPILER_LAUNCHER: sccache run: | @@ -851,6 +891,9 @@ jobs: with: fetch-depth: 1 + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + - name: Setup Rust bootstrap (Linux) uses: ./.github/actions/setup-rust-bootstrap-linux @@ -936,6 +979,9 @@ jobs: - name: Fetch skills submodule run: git submodule update --init --depth=1 skills + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + - name: Free disk space run: node scripts/ci.mjs free-disk-space @@ -966,7 +1012,7 @@ jobs: cmake binutils mold clang protobuf-compiler libopenblas-dev vulkan-sdk - version: tauri-rust-check-v6 + version: tauri-rust-check-v7 - name: Install cargo-llvm-cov run: cargo install cargo-llvm-cov --locked @@ -982,7 +1028,7 @@ jobs: --exclude iroh-example-client \ --exclude skill \ --no-default-features \ - --features llm-native,skill-router/cpu,skill-daemon/cpu-only \ + --features llm-rlx-cpu,skill-router/cpu,skill-daemon/cpu-only \ --lcov \ --output-path lcov.info @@ -992,7 +1038,7 @@ jobs: --exclude iroh-example-client \ --exclude skill \ --no-default-features \ - --features llm-native,skill-router/cpu,skill-daemon/cpu-only \ + --features llm-rlx-cpu,skill-router/cpu,skill-daemon/cpu-only \ --summary-only \ --fail-under-lines ${COVERAGE_MIN_LINES} @@ -1022,6 +1068,9 @@ jobs: with: fetch-depth: 1 + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + - name: Setup Node.js uses: actions/setup-node@v6 with: diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 8d68df0e..db6739d3 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -164,6 +164,9 @@ jobs: ref: ${{ steps.meta.outputs.ref }} fetch-depth: 0 + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + # ── Build label (version string used in artifact names + PR comment) ──── - name: Compute build label id: label diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 2ee49f6c..2ce7128f 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -38,6 +38,9 @@ jobs: with: fetch-depth: 0 # full history for git tag --format and changelog generation + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + # ── Verify tag matches tauri.conf.json version ──────────────────────────── - name: Resolve version id: version_meta @@ -164,12 +167,15 @@ jobs: shell: bash run: | set -euo pipefail - # Build both in one invocation for consistent feature unification + # Build both in one invocation for consistent feature unification. + # NOTE: `|| return $?` is required — `set -e` is inhibited inside a + # function called via `if ! cmd`, so a failing cargo build would + # silently fall through and run_cmd would still return 0. run_cmd() { # Build daemon first — its default features compile llama-cpp-sys-4 # with mtmd+vulkan+q1 via skill-llm's target-specific deps. - cargo build -p skill-daemon --release --locked --target x86_64-unknown-linux-gnu --timings - cargo build -p skill --release --locked --target x86_64-unknown-linux-gnu --features custom-protocol --timings + cargo build -p skill-daemon --release --locked --target x86_64-unknown-linux-gnu --timings || return $? + cargo build -p skill --release --locked --target x86_64-unknown-linux-gnu --features custom-protocol --timings || return $? } if ! run_cmd; then if [[ -n "${LLAMA_PREBUILT_DIR:-}" ]]; then diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml index 956434c3..87a79ba5 100644 --- a/.github/workflows/release-mac.yml +++ b/.github/workflows/release-mac.yml @@ -26,6 +26,9 @@ jobs: with: fetch-depth: 0 # full history so git tag --format can read the annotation + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + # ── Verify tag matches tauri.conf.json version ──────────────────────────── # Catches "forgot to bump the version" before wasting 20 min on a build. - name: Resolve version @@ -170,59 +173,17 @@ jobs: set -euo pipefail npm run build npm run -s verify:tauri:frontend - # Build both skill and skill-daemon in a single cargo invocation so - # feature unification produces one consistent llama-cpp-sys-4 build - # (with mtmd + metal + q1). Two separate invocations can cause cargo - # to reuse a cached llama-cpp-sys-4 static lib that lacks mtmd symbols. + # NOTE: `|| return $?` after each cargo invocation is required. + # `set -e` is inhibited inside a function called via `if ! cmd` / + # captured exit code, so without explicit `|| return` a failing + # cargo build would silently continue and run_cmd would still return 0. run_cmd() { - # Build daemon first — its default features (llm + embed-exg) compile - # llama-cpp-sys-4 with mtmd+metal+q1 via skill-llm's target-specific deps. - # Building skill second reuses the cached native libs. - cargo build -p skill-daemon --release --locked --target aarch64-apple-darwin --timings - cargo build -p skill --release --locked --target aarch64-apple-darwin --features custom-protocol --timings - - # Fix @rpath for skill-daemon immediately after build - # llama-cpp-4 builds .dylib files by default; we need to embed them - # in the .app bundle and update the binary's search paths - DAEMON_BIN="src-tauri/target/aarch64-apple-darwin/release/skill-daemon" - if [ -f "$DAEMON_BIN" ]; then - echo "Fixing @rpath for skill-daemon…" - - # Create Frameworks directory if it doesn't exist - mkdir -p "src-tauri/target/aarch64-apple-darwin/release/Frameworks" - - # Copy any .dylib files that the daemon depends on - for LIB in libggml-base.0.dylib libllama.1.dylib; do - if [ -f "src-tauri/target/aarch64-apple-darwin/release/$LIB" ]; then - cp "src-tauri/target/aarch64-apple-darwin/release/$LIB" \ - "src-tauri/target/aarch64-apple-darwin/release/Frameworks/" - install_name_tool -change @rpath/$LIB @executable_path/../Frameworks/$LIB "$DAEMON_BIN" - fi - done - fi + cargo build -p skill-daemon --release --locked --target aarch64-apple-darwin --timings || return $? + cargo build -p skill-tty --release --locked --target aarch64-apple-darwin --timings || return $? + cargo build -p skill --release --locked --target aarch64-apple-darwin --features custom-protocol --timings || return $? } if ! run_cmd; then - if [[ -n "${LLAMA_PREBUILT_DIR:-}" ]]; then - echo "::warning::macOS release compile failed with prebuilt llama; retrying with source build" - unset LLAMA_PREBUILT_DIR LLAMA_PREBUILT_SHARED - # Clean stale llama-cpp-sys artifacts so cargo rebuilds from source - find src-tauri/target -name "*llama_cpp_sys*" -exec rm -rf {} + 2>/dev/null || true - find src-tauri/target -name "*llama-cpp-sys*" -exec rm -rf {} + 2>/dev/null || true - # Also clean skill-daemon artifacts — the failed link leaves - # partial state that cargo considers "fresh", so without this - # the retry skips rebuilding the daemon entirely. - find src-tauri/target -name "*skill_daemon*" -exec rm -rf {} + 2>/dev/null || true - find src-tauri/target -name "*skill-daemon*" -not -path "*/skill-daemon-*" -exec rm -rf {} + 2>/dev/null || true - run_cmd - DAEMON_BIN="src-tauri/target/aarch64-apple-darwin/release/skill-daemon" - if [ ! -f "$DAEMON_BIN" ]; then - echo "::error::Daemon binary not found after source-build retry: $DAEMON_BIN" - exit 1 - fi - echo "::notice::Source build succeeded after prebuilt llama failure" - else - exit 1 - fi + exit 1 fi # ── Assemble + sign .app bundle ───────────────────────────────────── diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index ace22bb4..c9dead0a 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -35,6 +35,9 @@ jobs: with: fetch-depth: 0 + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + - name: Validate Windows app manifest shell: bash run: node scripts/check-windows-manifest.mjs src-tauri/manifest.xml @@ -121,63 +124,11 @@ jobs: cache-targets: false cache-on-failure: false - # ── ONNX Runtime (official binaries) ─────────────────────────────────── - # Use the official Microsoft release archive instead of relying on - # ort-sys CDN downloads. This makes Windows release builds deterministic - # and ensures onnxruntime.dll is available for packaging. - - name: Cache ONNX Runtime (Windows x64) - uses: actions/cache@v5 - with: - path: ${{ env.USERPROFILE }}\.cache\onnxruntime - key: windows-onnxruntime-1.23.2-x64 - - - name: Install ONNX Runtime (Windows x64) + export ORT env - shell: powershell - run: | - $OrtVersion = "1.23.2" - $OrtBase = Join-Path $env:USERPROFILE ".cache\onnxruntime" - $OrtRoot = Join-Path $OrtBase ("onnxruntime-win-x64-" + $OrtVersion) - $OrtDll = Join-Path $OrtRoot "lib\onnxruntime.dll" - $OrtLib = Join-Path $OrtRoot "lib\onnxruntime.lib" - - if (-not ((Test-Path $OrtDll) -and (Test-Path $OrtLib))) { - New-Item -ItemType Directory -Force -Path $OrtBase | Out-Null - $ZipPath = Join-Path $env:RUNNER_TEMP "onnxruntime-win-x64.zip" - $Url = "https://github.com/microsoft/onnxruntime/releases/download/v$OrtVersion/onnxruntime-win-x64-$OrtVersion.zip" - - $ok = $false - for ($i = 1; $i -le 8; $i++) { - try { - Invoke-WebRequest -Uri $Url -OutFile $ZipPath - $ok = $true - break - } catch { - if ($i -eq 8) { throw } - Start-Sleep -Seconds 2 - } - } - if (-not $ok) { - Write-Error "Failed to download ONNX Runtime after retries" - exit 1 - } - - Remove-Item -Recurse -Force $OrtRoot -ErrorAction SilentlyContinue - Expand-Archive -Path $ZipPath -DestinationPath $OrtBase -Force - } - - if (-not ((Test-Path $OrtDll) -and (Test-Path $OrtLib))) { - Write-Error "ONNX Runtime files missing: $OrtDll / $OrtLib" - exit 1 - } - - "ORT_LIB_LOCATION=$OrtRoot\lib" | - Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "ORT_PREFER_DYNAMIC_LINK=1" | - Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "PATH=$OrtRoot\lib;$OrtRoot\bin;$env:PATH" | - Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - Write-Host "[ok] ORT_LIB_LOCATION=$OrtRoot\lib" + # ── ONNX Runtime — not needed on Windows ────────────────────────────── + # KittenTTS (the only `ort` consumer) is gated to non-Windows targets in + # src-tauri / skill-tts Cargo.toml, so Windows builds don't link or ship + # onnxruntime.dll. Add the install/cache steps back here if a Windows + # TTS backend that needs ONNX Runtime is reintroduced. # ── Build cache (sccache) ───────────────────────────────────────────────── - name: Setup sccache @@ -225,6 +176,10 @@ jobs: # toolchain. Installing them in a single step via background jobs cuts # ~1-2 min of sequential wait time on cache-miss runs. - name: Cache Vulkan SDK + # Don't fail the job on transient cache-service errors (actions/cache@v5 + # occasionally returns "failure" instead of "miss"). The install step + # below handles a missing SDK by downloading it on the spot. + continue-on-error: true uses: actions/cache@v5 with: path: C:\VulkanSDK @@ -336,6 +291,22 @@ jobs: shell: bash run: node scripts/ci.mjs download-llama windows x86_64-pc-windows-msvc vulkan + # ── OpenBLAS (upstream binary release) ──────────────────────────────────── + # rlx-cpu emits `cargo:rustc-link-lib=openblas` on all non-macOS targets, + # so the Windows link step needs `openblas.lib` + `openblas.dll`. We + # download the upstream OpenBLAS Windows archive because vcpkg's port + # omits LAPACK and rlx-cpu's `blas` feature calls dgesv_/sgesv_. + # Cached so the download only happens once per script change. + - name: Cache OpenBLAS (upstream Windows binary) + uses: actions/cache@v5 + with: + path: C:\OpenBLAS + key: windows-openblas-${{ hashFiles('scripts/install-openblas-windows.ps1') }} + + - name: Install OpenBLAS + export OPENBLAS_DIR + shell: powershell + run: .\scripts\install-openblas-windows.ps1 + # ── Ensure VC++ Redistributable DLLs are available ─────────────────────── # The binary uses dynamic CRT (/MD). The NSIS installer bundles the CRT # DLLs app-locally alongside skill.exe. This step ensures they exist on @@ -457,10 +428,10 @@ jobs: exit 1 } - # ── Standalone LLVM 19 for bindgen + lld-link ───────────────────────────── + # ── Standalone LLVM 19 for bindgen + lld-link ────��──────────────────────── # VS2022's bundled clang-cl can't handle __builtin_ia32_* intrinsics in - # mmintrin.h, causing bindgen (llama-cpp-sys-4) to fail. Standalone - # LLVM 19 fixes this and also provides lld-link for faster linking. + # mmintrin.h, causing bindgen to fail. Standalone LLVM 19 fixes this + # and also provides lld-link for faster linking. # # Cache the full install so subsequent runs skip the ~500 MB download. - name: Cache LLVM 19 @@ -513,8 +484,6 @@ jobs: # --features custom-protocol: required — without it Tauri compiles in # dev mode and loads UI from localhost:1420 instead of the embedded # SvelteKit build output. - # --features llm-vulkan: enables Vulkan GPU offloading for LLM inference - # (covers NVIDIA, AMD, Intel Arc without vendor-specific SDKs). - name: Compile (Rust) shell: bash env: @@ -546,37 +515,33 @@ jobs: # Build both packages in a single cargo invocation so feature # unification happens once and shared dependencies compile only once. + # NOTE: `|| return $?` is required — `set -e` is inhibited inside a + # function called via `if ! cmd`, so a failing cargo build would + # silently fall through and run_cmd would still return 0. run_cmd() { - cargo build -p skill-daemon -p skill --release --locked --target x86_64-pc-windows-msvc --features custom-protocol --timings + cargo build -p skill-daemon -p skill --release --locked --target x86_64-pc-windows-msvc --features custom-protocol --timings || return $? } if ! run_cmd; then - if [[ -n "${LLAMA_PREBUILT_DIR:-}" ]]; then - echo "::warning::Windows release compile failed with prebuilt llama; retrying with source build" - unset LLAMA_PREBUILT_DIR LLAMA_PREBUILT_SHARED - find src-tauri/target -name "*llama_cpp_sys*" -exec rm -rf {} + 2>/dev/null || true - find src-tauri/target -name "*llama-cpp-sys*" -exec rm -rf {} + 2>/dev/null || true - run_cmd - else - exit 1 - fi + exit 1 fi sccache --show-stats || true - # Make sure NSIS packaging can always find onnxruntime.dll next to the - # built binary, independent of Cargo OUT_DIR layout details. - - name: Stage onnxruntime.dll for packaging + # rlx-cpu and turbovec resolve cblas_sgemm via openblas.dll at runtime. + # Bundle it next to skill.exe / skill_daemon.exe so the installed app + # works on machines without OpenBLAS preinstalled. The upstream 0.3.30 + # DLL is statically linked against the MinGW runtime (depends only on + # KERNEL32.dll + msvcrt.dll), so no extra runtime DLLs need shipping. + - name: Stage openblas.dll for packaging shell: powershell run: | - if (-not $env:ORT_LIB_LOCATION) { - Write-Error "ORT_LIB_LOCATION is not set" + if (-not $env:OPENBLAS_DLL) { + Write-Error "OPENBLAS_DLL is not set (install-openblas-windows.ps1 must run before this step)" exit 1 } - - $Dll = Join-Path $env:ORT_LIB_LOCATION "onnxruntime.dll" - if (-not (Test-Path $Dll)) { - Write-Error "onnxruntime.dll not found at $Dll" + if (-not (Test-Path $env:OPENBLAS_DLL)) { + Write-Error "openblas.dll not found at $env:OPENBLAS_DLL" exit 1 } @@ -587,8 +552,8 @@ jobs: foreach ($dir in $releaseDirs) { New-Item -ItemType Directory -Force -Path $dir | Out-Null - Copy-Item $Dll (Join-Path $dir "onnxruntime.dll") -Force - Write-Host "[ok] Copied onnxruntime.dll to $dir" + Copy-Item $env:OPENBLAS_DLL (Join-Path $dir "openblas.dll") -Force + Write-Host "[ok] Copied openblas.dll to $dir" } # ── Upload cargo build timings ─────────────────────────────────────────── @@ -906,7 +871,7 @@ jobs: # ── Discord notification ────────────────────────────────────────────────── - name: Notify Discord of release - if: always() && steps.version_meta.outputs.dry_run != 'true' + if: always() && steps.version_meta.outputs.is_release == 'true' shell: bash env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.gitignore b/.gitignore index 0adb93bc..7da905a3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules .env.* !.env.example !.envrc +rlx.path vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 6918cd4c..9f919621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4626,3 +4626,1066 @@ Past releases are archived in [`changes/releases/`](changes/releases/). - Better updater configuration --- + +## [0.0.130-rc.1] — 2026-04-29 + +### Features + +- **Human vs AI activity tracking**: every edit and commit is now classified as `source: "human"` or `source: "ai"` in real-time. `AIActivityTracker` watches VSCode commands (Copilot/Codeium completions, inline chat, AI-generated commit messages) to tag subsequent edits/commits, exposes `getAIRatioForFile()` (rolling 5-minute ratio) for CodeLens + sidebar, and forwards the `source` field to the daemon for `build_events` / `ai_events` storage. + +## How it works + +The `AIActivityTracker` monitors VSCode command execution to detect AI tool usage: + +- **Inline completions** (Copilot, Codeium) — edits within 5 seconds after an AI command are tagged `source: "ai"` +- **Inline chat** (`inlineChat.start`, `copilot.interactiveEditor.*`) — subsequent edits in the same file are tagged AI +- **Commit messages** — `github.copilot.git.generateCommitMessage` marks the next commit as AI-assisted +- **Everything else** — classified as `source: "human"` + +## What's tracked + +| Signal | Classification | +|--------|---------------| +| Manual typing | `human` | +| Copilot inline suggestion accepted | `ai` | +| Copilot inline chat edits | `ai` | +| Paste from external source | `human` | +| AI-generated commit message | `ai` | +| Manually typed commit message | `human` | + +## Per-file AI ratio + +`AIActivityTracker.getAIRatioForFile(path)` returns a rolling 5-minute ratio (0.0 = all human, 1.0 = all AI) used by: +- CodeLens annotations (shows "AI-Assisted" vs focus score) +- Sidebar (Human/AI percentage display) +- Brain status command (Human/AI split) + +## Daemon integration + +The `source` field is sent on every `edit` and `git_commit` event to the daemon. The daemon stores: +- AI commits as `"git commit (ai-assisted)"` in `build_events` +- AI commits also as `ai_events` for analytics weighting +- Completion acceptances as `ai_events` with type `"suggestion_accepted"` + +## Files + +- `src/ai-tracker.ts` — Core tracker (new) +- `src/events.ts` — Wired to classify edits and commits +- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Daemon-side storage + +- **Focus-aware code review CodeLens**: annotations at the top of each file show the developer's focus level when the file was last edited (`⚠ Low Focus (42)`, `ℹ Focus: 65/100`, `🤖 AI-Assisted (85%)`, or none for high focus). `NeuroSkill: Show Files Needing Review` command lists low-focus human-authored files. Toggle via `neuroskill.focusCodeLens`. + +## What you see + +- `⚠ Low Focus (42) — Review Recommended` — File was edited during low focus. Click to see all files needing review. +- `ℹ Focus: 65/100` — Moderate focus, informational only. +- `🤖 AI-Assisted (85%)` — Most edits were AI-generated, focus score not applicable. +- No annotation — High focus (>70) or no data yet. + +## Commands + +**NeuroSkill: Show Files Needing Review** (`Cmd+Shift+P`) +- Shows a QuickPick list of files edited during low focus (<50) that were mostly human-authored +- Sorted by focus score (lowest first) +- Select a file to open it + +## How it works + +- `FocusCodeLensProvider` queries `/brain/cognitive-load` (grouped by file) every 30 seconds +- Combines focus data with `AIActivityTracker.getAIRatioForFile()` to distinguish human vs AI code +- Files with high AI ratio (>70%) show AI label instead of focus score — AI code doesn't reflect human cognitive state + +## Settings + +`neuroskill.focusCodeLens` (default: `true`) — Toggle CodeLens annotations on/off. + +## Files + +- `src/codelens-provider.ts` — CodeLens provider (new) + +- **Smart Interruption Shield**: when `/brain/flow-state` reports `in_flow: true`, VS Code's Do Not Disturb mode auto-enables and the status bar shows `$(shield) In Flow 12m`. `NeuroSkill: Toggle Flow Shield` cycles Auto / Forced-on / Forced-off. Toggle via `neuroskill.flowShield`. + +## How it works + +- When `/brain/flow-state` reports `in_flow: true`, the Flow Shield activates +- Enables VSCode's Do Not Disturb mode (VSCode 1.90+) +- Shows `$(shield) In Flow 12m` in the status bar with elapsed time +- When flow state ends, DND is automatically disabled + +## Manual override + +**NeuroSkill: Toggle Flow Shield** (`Cmd+Shift+P`) + +Cycles through three modes: +1. **Auto** (default) — activates/deactivates based on EEG flow detection +2. **Forced on** — always active regardless of flow state +3. **Forced off** — never active + +## Settings + +`neuroskill.flowShield` (default: `true`) — Enable/disable the flow shield feature. + +## Files + +- `src/flow-shield.ts` — Flow shield implementation (new) +- `src/brain.ts` — Calls `flowShield.update()` every 30s + +- **Adaptive Break Coach**: personalized break timing based on the developer's actual EEG focus cycle (via `/brain/break-timing`), not generic Pomodoro. Status bar countdown `$(clock) Break in 8m`, max one notification per cycle, auto-resets when fatigue indicates idleness. `NeuroSkill: Take a Break` resets the timer manually. Toggle via `neuroskill.breakCoach`. + +## How it works + +- Queries `/brain/break-timing` to learn the developer's natural focus cycle length +- Shows a countdown in the status bar: `$(clock) Break in 8m` +- When the predicted focus drop is imminent (<5 min), the countdown turns visible +- When the cycle ends, shows `$(clock) Break time` and optionally notifies + +## Notifications + +- Max one notification per focus cycle +- Message: "You've been focused for 47m. Your natural cycle is 42m — take a break?" +- Buttons: "Take Break" (resets timer) or "Dismiss" + +## Timer sync + +The break coach automatically syncs with the daemon's fatigue data. If `continuous_work_mins` drops below 5 (indicating the user was idle), the session timer resets — no false break suggestions after returning from lunch. + +## Commands + +**NeuroSkill: Take a Break** (`Cmd+Shift+P`) — Manually acknowledge a break and reset the timer. + +## Settings + +`neuroskill.breakCoach` (default: `true`) — Enable/disable break coaching. + +## Files + +- `src/break-coach.ts` — Break coach implementation (new) +- `src/brain.ts` — Calls `breakCoach.refresh()` and `resetSessionIfIdle()` every 30s + +- **Struggle → AI assist bridge**: when `/brain/struggle-predict` flags a file (EEG focus + undo rate + velocity drop + time-on-file), shows an actionable notification with **Open Copilot Chat**, **Open Terminal**, and **Step Back** buttons. Debounced to one suggestion per file per 10 minutes. Toggle via `neuroskill.struggleBridge`. + +## How it works + +- Monitors `/brain/struggle-predict` (EEG focus + undo rate + velocity drop + time-on-file) +- When `struggling: true`, shows an actionable notification: + > "Stuck on auth.ts? (score: 78) Consider breaking the problem into smaller pieces." + +## Action buttons + +| Button | Action | +|--------|--------| +| **Open Copilot Chat** | Opens GitHub Copilot interactive chat (or generic chat panel) | +| **Open Terminal** | Toggles terminal for CLI debugging | +| **Step Back** | Dismiss and take a mental break | + +## Debouncing + +- Max one suggestion per file per 10 minutes +- Prevents notification fatigue while still catching genuine struggles + +## Settings + +`neuroskill.struggleBridge` (default: `true`) — Enable/disable struggle detection and AI suggestions. + +## Files + +- `src/struggle-bridge.ts` — Struggle bridge implementation (new) +- `src/brain.ts` — Calls `struggleBridge.check()` every 30s (replaces the old generic struggle notification) + +- **Personal Flow Triggers dashboard**: collapsible "Your Flow Recipe" sidebar section mining 7-day EEG + activity history — best language (`/brain/code-eeg`), peak hours (`/brain/optimal-hours`), natural cycle length (`/brain/break-timing`), top flow killer (`/brain/context-cost`). Toggle via `neuroskill.flowTriggers`. + +## What you see + +In the NeuroSkill sidebar panel, a collapsible "Your Flow Recipe" section shows: + +- **Best language** — "Focus best on Rust (82)" — from `/brain/code-eeg` +- **Peak hours** — "Peak hours: 9:00, 10:00, 14:00" — from `/brain/optimal-hours` +- **Natural cycle** — "Natural cycle: 42m" — from `/brain/break-timing` +- **Flow killer** — "Flow killer: Slack (focus 38 at switch)" — from `/brain/context-cost` + +## Data sources + +| Insight | API Endpoint | Time Range | +|---------|-------------|------------| +| Best languages | `/brain/code-eeg` | Last 7 days | +| Peak hours | `/brain/optimal-hours` | Last 7 days | +| Natural cycle | `/brain/break-timing` | Last 7 days | +| Flow killers | `/brain/context-cost` | Last 7 days | + +## Settings + +`neuroskill.flowTriggers` (default: `true`) — Show/hide the flow triggers section in the sidebar. + +## Files + +- `src/sidebar.ts` — `_fetchFlowTriggers()` and `_renderFlowTriggers()` methods + +- **Focus-scored git commits in sidebar**: collapsible "Recent Commits" section shows the last 8 commits with author marker (👤 human / 🤖 AI) and a color-coded focus badge captured at commit time via `/brain/flow-state`. AI-assisted commits show "AI" instead of a focus score. Toggle via `neuroskill.focusCommits`. + +## What you see + +In the NeuroSkill sidebar, a collapsible "Recent Commits" section shows the last 8 commits: + +``` +👤 82 fix: resolve auth race condition +👤 45 chore: update dependencies +🤖 AI refactor: extract helper functions +👤 71 feat: add user preferences +``` + +- **👤** = human-authored commit +- **🤖** = AI-assisted commit (message generated by Copilot) +- **Focus badge** = color-coded: green (>70), yellow (40-70), red (<40) +- AI commits show "AI" instead of a focus score — AI output doesn't measure human cognition + +## How it works + +- When the extension detects a git commit (SCM input box clears), it: + 1. Snapshots current EEG focus via `/brain/flow-state` + 2. Checks `AIActivityTracker.isCommitAIAssisted()` + 3. Records the commit with focus score + source label +- Commits stored in-memory (last 15), refreshed on sidebar render +- The daemon also stores commits with human/AI distinction in `build_events` + +## Settings + +`neuroskill.focusCommits` (default: `true`) — Show/hide the commits section in the sidebar. + +## Files + +- `src/sidebar.ts` — `recordCommit()`, `_renderCommits()` +- `src/extension.ts` — Wires commit detection to sidebar recording +- `src/events.ts` — `onCommit` callback with human/AI source + +- **Optimal Task Router**: monitors flow score every 30 s and, when it changes by >20 points, suggests an appropriate task type (refactoring/new features at high focus, code review at moderate, docs/routine at low). Debounced to one suggestion per 15 minutes. Toggle via `neuroskill.taskRouter`. + +## How it works + +- Monitors the flow state score every 30 seconds +- When focus changes by >20 points from the last reading, suggests an appropriate task type: + +| Focus Level | Suggestion | +|------------|------------| +| >75 | "Focus is high (85) — great time for complex work like refactoring or new features." | +| 45-75 | "Focus moderate (58) — good for code review, testing, or incremental tasks." | +| <45 | "Focus low (32) — consider documentation, routine tasks, or a break." | + +## Debouncing + +- Maximum one suggestion every 15 minutes +- No suggestion on the first reading (establishes baseline) +- No suggestion if focus stays within 20 points of the last reading + +## Settings + +`neuroskill.taskRouter` (default: `true`) — Enable/disable task routing suggestions. + +## Files + +- `src/task-router.ts` — Task router implementation (new) +- `src/brain.ts` — Calls `taskRouter.check()` every 30s + +- **Activity dashboard tab**: new Settings tab with daily summary, productivity score (0-100 composite), hourly heatmap, top files/projects, language breakdown, focus sessions, meetings, and stale file alerts. +- **Focus session replay**: click any session to expand a detailed view with file timeline, edit counts, EEG focus overlay bar, and meeting interruptions. +- **Weekly report with CSV export**: 7-day activity digest with daily breakdown chart and one-click CSV download. +- **Productivity scoring**: composite score from edit velocity + deep work time + context stability + EEG focus. +- **Stale file detection**: files edited but untouched for 7+ days. +- **Code-Brain correlation**: focus ranked by language, project, and individual files — shows which code drains or energizes you. +- **Activity timeline**: unified chronological feed of all events (files, builds, meetings, AI, clipboard) with EEG focus indicators. + +- **Brain awareness API**: 14 endpoints under `/v1/brain/*` — flow state, cognitive load, meeting recovery, optimal hours, fatigue check, undo struggle, daily brain report, break timing, deep work streak, task type detection, struggle prediction, interruption recovery, code-EEG correlation, and unified timeline. +- **Flow state detector**: real-time check if user is in deep focus (high focus + low switches + sustained editing). +- **Task type detection**: auto-classify coding/debugging/reviewing/refactoring/testing from activity patterns. +- **Struggle prediction**: fuse undo rate + velocity drop + focus decline to predict when user is stuck. Includes actionable suggestion. +- **Interruption recovery measurement**: measure actual focus recovery time per interruption source (Slack avg 12min, Zoom avg 23min, etc.). +- **Fatigue monitor**: 15-minute background check broadcasts `fatigue-alert` and sends OS notification when focus declines. +- **Daily brain report**: 6pm OS notification with morning/afternoon/evening brain summary. +- **Weekly digest notification**: Monday 9am OS notification with weekly summary. ISO week dedup prevents re-fire on daemon restart. +- **Break timing optimizer**: detect natural focus cycle length from 5-minute EEG buckets. +- **Deep work streak**: gamified consecutive days with 60+ minutes of deep work. + +- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable EEG-fused insights in one call. +- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — shows how focus level predicts test outcomes. +- **Hourly productivity**: churn volume + undo rate + avg EEG focus by hour of day — identifies peak and trough hours. +- **Context switch recovery**: EEG focus level at each editor/terminal/panel transition — measures the cognitive cost of switching. +- **AI tool impact on focus**: per-app (Claude, Pi) focus delta vs baseline — quantifies whether AI tools help or hurt flow state. +- **Focus by language**: avg EEG focus + undo rate per programming language — identifies which languages are cognitively easiest/hardest. +- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day — shows when edit-test loops are fastest. +- **Tool focus impact**: avg EEG focus per command category (docker, git, deploy, etc.) — reveals which tools correlate with lowest focus. +- **EEG timeseries table**: periodic JSON snapshots of all brain metrics (every 5s during recording). Extensible — new metrics without schema changes. Any event correlatable by timestamp join. +- **EEG timeseries worker**: background thread writes full band powers to `eeg_timeseries` every 5s when an EEG session is active. +- **`POST /v1/brain/eeg-at`**: get EEG metrics at a specific timestamp (nearest sample). +- **`POST /v1/brain/eeg-range`**: get EEG time-series in a range for charts and correlation analysis. +- **Window focus tracking**: `window_focus` events sent when VS Code gains/loses focus. EEG not attributed to coding when VS Code is in background. +- **Live EEG attachment**: `latest_bands` focus/mood injected at event storage time for terminal commands, zone switches, and conversations. + +- **`mlx-e2e` test suite**: new suite in `scripts/test-all.sh` running UMAP and FFT e2e tests with MLX features. Auto-skips on non-macOS. Available via `npm run test:mlx-e2e`. + +- **Conversations table**: stores all AI coding assistant messages (Claude, Pi) with app, role (user/assistant/tool), text, cwd, timestamp, session ID, EEG focus/mood. +- **FTS5 full-text search**: SQLite virtual table for instant keyword search across all conversation text. `POST /v1/brain/search-conversations {"query":"JWT","mode":"fts"}`. +- **Fuzzy search**: LIKE-based substring matching for partial queries. `{"query":"rate limit","mode":"fuzzy"}`. +- **Structured search**: filter by app, role, time range. `{"mode":"structured","app":"claude","role":"user","since":...}`. +- **Semantic search**: user prompts embedded via fastembed (nomic-embed-text-v1.5, local, no API credits) and stored in HNSW label index. Searchable by meaning, not just keywords. +- **Generic embedding store**: `embeddings` table decoupled from specific data tables. Multi-model support — can re-embed with different models, store multiple vectors per item. Source tracking (source_type, source_id). +- **Code context HNSW index**: separate `code_context_index.hnsw` file for code-specific semantic search, keeping EEG label searches uncontaminated. +- **Conversation events**: VS Code extension sends `conversation_message` events to daemon for each Claude/Pi message. User prompts get embedded; assistant responses and tool calls get FTS-indexed only (saves compute). +- **Session tracking**: messages grouped by JSONL filename (session ID). Timestamps from app's own data (ISO 8601 UTC), not extension read time. +- **Embedding settings**: `neuroskill.embedding.maxInputLength` (default 1000 chars) and `neuroskill.embedding.enableConversations` (default true) in VS Code settings. + +- **Meeting detection**: automatically detect Zoom, Teams, Slack, Google Meet, FaceTime, Discord, and Webex meetings from window titles. Track start/end times in `meeting_events` table. +- **Browser tab extraction**: extract page titles from Chrome, Safari, Firefox, Edge, Brave, Arc, Opera, Vivaldi, Chromium. Stored in `browser_title` column on `active_windows`. +- **Clipboard monitoring**: opt-in macOS clipboard change tracking (metadata only — content never stored). Records source app, content type, and size. Includes Automation permission check and settings UI. +- **Multi-monitor awareness**: track windows on secondary monitors across macOS (AppleScript), Linux (wmctrl), Windows (EnumWindows). Dynamic primary screen resolution detection. +- **Undo frequency tracking**: detect undo/redo from file edit diffs via reversal heuristic. `undo_estimate` per 5-second chunk, `undo_count` per file interaction. + +- **Design tokens** (`webview-ui/src/lib/tokens.css`): 30+ CSS custom properties organized into core palette (5 colors), surfaces (4 levels), backgrounds (14 translucent tints), borders (3 colors), semantic aliases (7), and typography (3). Zero inline `rgba()` in App.svelte. +- **Reusable Svelte components** (`webview-ui/src/lib/`): + - `Card` — wrapper with title, info button, info panel, header-right slot, variant borders (warn/danger/info) + - `MetricRow` — label + value pair with variant colors (warn/accent/dim/good/bad) + - `Chevron` — collapsible section with chevron toggle, count badge, slot content + - `ProgressBar` — horizontal bar with value/max, 5 color variants, optional label + - `Gauge` — circular SVG ring with animated fill, value, label + - `Badge` — text badge with 7 variants (default/good/warn/bad/blue/live/score-circle/si) + - `Callout` — alert box with 3 variants (warn/danger/info) +- **Component index** (`webview-ui/src/lib/index.ts`): barrel export for all components. +- **Timestamp compliance**: 25 automated tests (`src/test-timestamps.ts`) verifying: + - No `toLocaleTimeString`/`toLocaleDateString` in data layer (activity-tracker, events, brain, config, vt-parser) + - `toLocaleTimeString` used in UI layer (App.svelte) for display + - `Date.now()` returns UTC milliseconds + - ISO 8601 strings parsed to UTC millis + - No hardcoded timezone offsets in data layer + - All stored timestamps are UTC; local conversion only at UI boundary + +- **Dev loop detection**: identifies edit-build-test cycles from terminal command history. Groups consecutive runs of the same build/test command into loops. +- **`dev_loops` table**: loop type, command, iteration count, pass/fail counts, avg cycle time, fastest/slowest cycle, EEG focus start/end, focus trend (rising/falling/stable). +- **`POST /v1/brain/dev-loops`**: returns detected loops for a time window with all metrics. +- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages." +- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl → infrastructure (0.8), deploy → deploying (0.85), git dominating → git_management (0.7), test → testing (0.85), debug → debugging (0.9). +- **Dev loops in sidebar**: command, iteration count, pass rate, avg cycle time, focus trend arrow. Failing loops get red left border. +- **Dev loops in Tauri Activity tab**: expanded view with pass rate percentage, cycle time, focus trend. +- **Loop efficiency by hour**: insight showing build/test cycle time + pass rate by time of day. + +- **Inference backend selector**: `exg_inference_device` setting expanded from `gpu | cpu` to `auto | mlx | gpu | cpu`. Default changed from `gpu` to `auto` (picks MLX on macOS, GPU elsewhere). +- **Four-button selector in EXG Settings**: Auto, MLX, GPU, CPU — each with localized label and description. Reconnect headset hint shown after change. +- **`embed-exg-mlx` feature**: new daemon Cargo feature that enables `skill-router/mlx`, `skill-eeg/mlx`, and `embed-zuna-mlx` together. + +- **fast-umap 1.6.0**: updated from 1.5.1 with MLX, PCA, and nearest-neighbor descent support. +- **MLX UMAP backend**: Apple Silicon native UMAP projection via `burn-mlx`. Runtime dispatch between MLX and GPU (wgpu) based on user preference. Auto defaults to MLX on macOS, GPU elsewhere. +- **Precision selector**: F32 / F16 precision for the GPU (wgpu) backend. MLX is F32-only (fast-umap trait constraint). Exposed in UMAP settings as chip group. +- **Backend & precision UI**: new "Compute Backend" section in UMAP settings with Auto / MLX / GPU chips and F32 / F16 precision chips. Pipeline summary badge shows active backend and precision. + +- **UMAP e2e benchmarks**: `umap_e2e_small` (200 pts), `umap_e2e_medium` (1K pts), `umap_e2e_large` (5K pts) with synthetic 32-dim EEG embeddings. Reports backend, timing, throughput (pts/sec), and separation score. + +- **gpu-fft 1.2.0**: updated from 1.1.1 with MLX FFT backend. EEG signal filtering (overlap-save convolution) uses MLX when `skill-eeg/mlx` is enabled. ~3.7x faster than wgpu at N=65536. +- **`skill-eeg/mlx` feature**: new feature gate that enables `gpu-fft/mlx` alongside `gpu-fft/wgpu`. Wired into `embed-exg-mlx` daemon feature. + +- **FFT MLX e2e**: `fft_e2e_roundtrip_256`, `fft_e2e_batch_4ch`, `fft_e2e_psd_peak_detection`, `fft_e2e_large_batch` (32 channels x 1024 samples). Validates round-trip accuracy, PSD peak detection, and batch throughput. + +- **Grayscale display mode**: optional system-wide grayscale that activates/deactivates in sync with Do Not Disturb. Reduces visual distraction during deep work. macOS only, default off. + +- **File activity in history sessions**: expanded EEG sessions show files worked on during the session with focus indicators, edit counts (+/-), language, and meeting interruption badges. +- **File activity in interactive search**: `file_activity` nodes in the 3D search graph linked to EEG epochs and text labels, showing which files were being edited during matching brain states. +- **Meeting nodes in search graph**: detected meetings appear alongside EEG results in the interactive search as amber nodes with temporal proximity edges. + +- **Git context card**: current branch (monospace), dirty/staged file count, ahead/behind indicators. Uses VS Code git extension API. +- **Session timeline card**: horizontal bar chart showing activity events by hour of day. Color-coded by volume. +- **Workspace activity card**: per-file edits, lines added/removed, focus time. Grouped by workspace folder (project), sorted by activity. Active file indicator (blue dot). Top 10 files per project. +- **Environment card**: editor/terminal/panel time split (colored stacked bar), tab count, editor groups, terminal count. Per-terminal cards with shell type, CWD, PID, shell integration status. +- **Terminal I/O sections**: collapsible with chevrons, minimized by default. Input shows timestamped commands with CWD. Output shows VT-parsed session content. +- **Terminal input card**: keystroke intensity per program (keys/min), duration, collapsible behind chevron. +- **Terminal impact card**: EEG focus delta by command category with pass rate percentage. +- **Context switch cost card**: focus level at each zone transition type with switch count. +- **Dev loops card**: edit-build-test cycles with iteration count, pass/fail rate, cycle time, focus trend (rising/falling/stable arrow). +- **Today's report card**: productivity score, morning/afternoon/evening period breakdown with focus bars and churn. +- **Energy card**: fatigue bar (inverse of focus decline), continuous work time, streak badge. +- **Struggle card**: EEG-only (hidden without recording), score 0-100, contributing factors. +- **Optimal hours card**: peak/avoid hours grid. +- **AI usage card**: acceptance rate bar, suggestion count, source breakdown. +- **Today vs yesterday card**: files and churn comparison with directional arrows. +- **Code review detection**: auto-detected when file switches > 5 and edit velocity < 1 line/min. +- **Process monitor card**: running dev servers (vite, next, webpack, cargo, node, python, docker, postgres) with ports and PIDs. +- **Info toggles**: every card has a `?` button explaining how metrics are calculated. +- **Dual-speed updates**: local data every 5s (visible), brain data every 30s. Pauses when sidebar hidden. + +- **OS-wide shell hooks**: preexec/precmd hooks for zsh, bash, fish, PowerShell. Background curl sends every command to daemon — zero delay to prompt. Self-contained scripts generated by daemon, stored in `~/.skill/shell-hooks/`. +- **Shell hook management**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check per shell. +- **Terminal command table**: `terminal_commands` with command text, binary name, args, cwd, exit code, duration, auto-categorized (284 CLI tools across 18 categories), EEG focus at start + end for delta calculation. +- **Binary extraction**: raw binary name stored separately from args. Lazy categorization — `POST /v1/brain/recategorize-commands` reruns rules on all historical data. `POST /v1/brain/binary-stats` discovers uncategorized tools by frequency. +- **284 CLI tools recognized**: git, gh, glab, hf, docker, kubectl, helm, aws, gcloud, az, brew, pip, cargo, npm, psql, redis-cli, ollama, claude, pi, vim, tmux, htop, terraform, and 260+ more across 18 categories (build, test, run, git, docker, deploy, install, navigate, debug, network, database, ai, editor, multiplexer, monitor, env, system, other). +- **Script session recording**: `script` PTY proxy wraps shell sessions, captures all terminal I/O (including input inside interactive programs). Logs stored in `~/.skill/terminal-logs/` with auto-rotation. +- **VT100 terminal emulator**: `vt-parser.ts` replays script recordings through a virtual terminal with scrollback capture. Handles cursor movement, screen clears, alternate screen buffer. Separates input from output via prompt pattern detection. +- **App-specific JSONL parsing**: reads Claude Code (`~/.claude/projects/`) and Pi agent (`~/.pi/agent/sessions/`) conversation files directly. Extracts user prompts with real timestamps, assistant responses, and tool calls. Supports multiple parallel sessions. +- **Readline history polling**: monitors `~/.python_history`, `~/.node_repl_history`, `~/.irb_history`, `~/.psql_history` for REPL input. +- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). +- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons with health indicators (green/yellow/red dot), recent commands preview, "How it works" documentation. + +- **Validation / fatigue-research daemon backend**: foundations for calibrating the Break Coach and Focus Score against four external instruments — KSS (Karolinska Sleepiness Scale), NASA-TLX (workload), PVT (Psychomotor Vigilance Task), and an EEG-derived fatigue index per Jap et al. 2009. +- **`skill-data::validation_store`** — new SQLite store at `~/.skill/validation.sqlite` with five tables (`config`, `kss_responses`, `tlx_responses`, `pvt_runs`, `prompt_log`). Persistent config is a single-row JSON blob with serde defaults so adding a new channel later is a non-migration. New constant `VALIDATION_FILE` in `skill-constants`. +- **EEG fatigue index**: pure function `eeg_fatigue_index(bands) → Option` computing `(α + θ) / β` over the existing band-power snapshot. Guards against missing bands and zero-β. Passive; ships **on** because it costs nothing when no headset is attached. +- **Pure scheduler `decide_prompt(ctx, ...) → PromptDecision`**: respects the `respect_flow` master gate, configurable quiet hours, per-channel daily caps, runtime snoozes, and rate limits. Channel ordering: KSS first (lightest), TLX after a long task unit, PVT on a weekly cadence. Live `read_in_flow` and `read_break_coach_active` open the activity store read-only and ask `flow_state_now(300).in_flow` and `fatigue_check().fatigued` so the gates actually mean something. +- **Sane defaults — opt-in everywhere**: KSS, TLX, PVT all ship `enabled = false`. Only the passive EEG fatigue index ships on. The `respect_flow` master gate ships on. + +- **Validation & Research settings tab** (`src/lib/settings/ValidationTab.svelte`): new top-level settings tab gathering five `SettingsCard` blocks — global gates (`respect_flow`, quiet hours), KSS, NASA-TLX, PVT, and EEG fatigue index — plus a Calibration Week button and a recent-results card. + - Every toggle / numeric input PATCHes one nested field via `daemonPatch("/v1/validation/config", …)`; the daemon's recursive JSON merge keeps the rest of the config untouched. + - EEG fatigue card shows the live `(α + θ) / β` value polled every 5 s from `/v1/validation/fatigue-index`. + - Calibration Week: single button that batch-updates all four channels to higher-frequency presets (KSS 8/day, TLX after every flow block ≥ 20 min, PVT mid-week) in one PATCH. +- **PVT panel** (`src/lib/settings/PvtPanel.svelte`): full 3-minute Psychomotor Vigilance Task. Random ITIs (2–10 s), green-dot stimulus, `performance.now()` for sub-millisecond RT measurement, tracks responses + false starts. On finish computes mean RT, median RT, slowest-10% mean RT (anticipation-resistant), and lapse count (RT > 500 ms per Dinges & Powell 1985), POSTs to `/v1/validation/pvt`. State machine: intro → running → done. +- **NASA-TLX form** (`src/lib/settings/TlxForm.svelte`): six-slider modal for the raw (un-weighted) NASA-TLX. Performance scale uses inverted endpoints ("Failure" → "Perfect") per Hart 2006. POSTs to `/v1/validation/tlx` with `task_kind`, `task_duration_secs`, optional `prompt_id` echo-back. +- **Pure stats helper** (`src/lib/settings/pvt-stats.ts`): extracted `mean`, `median`, `slowest10Mean`, `lapseCount`, `computeStats` from the PVT panel so the math is unit-testable without a Svelte renderer. +- **`daemonPatch(path, body)`** in `src/lib/daemon/http.ts` — needed for the validation config endpoint. + +- **Test coverage for the validation feature across all three layers** — 72 tests total, all passing. + - **Rust unit tests** (`crates/skill-data/src/validation_store.rs`): 19 tests covering config persistence, store round-trips, the `(α + θ) / β` fatigue index (Jap et al. 2009) including zero-β and missing-band edge cases, every scheduler branch (flow respect, quiet hours, snooze blocking, KSS rate limit, TLX trigger after long task, TLX skip on short task, PVT weekly cadence, PVT silence after recent run, quiet-window-equals-end semantics), and prompt-log queries. + - **Rust HTTP integration tests** (`crates/skill-daemon/src/routes/validation.rs`): 10 tests using `tower::ServiceExt::oneshot()` against a tempdir-backed `AppState` — `GET /config` defaults, `PATCH /config` partial-update + round-trip, `POST /kss` happy path, KSS score-out-of-range → 400, TLX subscale-out-of-range → 400, `GET /should-prompt` returns `{kind: "none"}` with default config, `POST /snooze` envelope, plus three `merge_json` tests covering deep nesting. + - **Vitest — PVT statistics** (`src/tests/pvt-stats.test.ts`, 12 tests): edge cases for `mean`, `median`, `slowest10Mean` (including the n<10 fallback), `lapseCount` with the 500 ms Dinges & Powell threshold and explicit-threshold override, plus a known-fixture `computeStats` round-trip. + - **Vitest — i18n coverage** (`src/tests/validation-i18n.test.ts`, 23 tests): every VS Code l10n bundle exists and contains every user-facing key (KSS prompt + scores 1/5/9, TLX/PVT prompts, all four escape-hatch labels); every Tauri language `validation.ts` imports cleanly; English Tauri bundle covers every required key; every non-English Tauri bundle has at least the tab name and disclaimer translated. + +- **VS Code extension joins the validation prompt loop**: new `ValidationManager` (`extensions/vscode/src/validation.ts`) polls `/v1/validation/should-prompt` every 90 s and renders whichever channel the daemon decides to fire. + - **KSS** (1–9 sleepiness) — full QuickPick with Karolinska wording, plus Snooze 30m / Don't ask today / Stop these prompts escape hatches in-line. POSTs the answer to `/v1/validation/kss` echoing the daemon's `prompt_id` so the prompt log can mark it answered. + - **NASA-TLX** — fallback path: shows an information message offering to open the form in the Tauri app (deep link `neuroskill://validation/tlx`) plus the same escape hatches. + - **PVT** — weekly nudge: offers to deep-link into the Tauri PVT panel (`neuroskill://validation/pvt`) or skip a week (snoozes for 6 days so the next reminder fires on cadence). +- **Two new commands**: `NeuroSkill: Open Validation Settings…` (deep links into the Tauri preferences pane) and `NeuroSkill: Check for Validation Prompt Now` (forces a single scheduler poll — useful for opt-in onboarding). +- **`DaemonClient.patch()`**: extension's daemon client gained a PATCH method so the "Stop these prompts" escape hatch can flip `enabled = false` on the persistent config. + +- **VS Code extension**: separate repo (`NeuroSkill-com/vscode-neuroskill`) as git submodule at `extensions/vscode/`. 50+ tracked event types across editing, navigation, debugging, git, AI, terminal, clipboard, and more. +- **Sidebar webview (Svelte 5)**: full brain dashboard in the VS Code activity bar with circular flow gauge, metrics strip, daily report, energy, struggle, optimal hours, workspace activity, environment, terminal impact, context cost, and dev loops. +- **Activity bar icon**: neural network SVG icon in the VS Code sidebar; NeuroSkill logo (PNG) in the webview header. +- **Brain status bar in VS Code**: polls daemon every 30s showing flow state, fatigue, streak, task type, and struggle score. Shows "offline" when daemon unreachable. Notifications for fatigue and struggle. +- **Dual-speed sidebar updates**: local data (files, terminals, layout) refreshes every 5s when sidebar is visible; brain endpoints every 30s. Pauses when hidden. +- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk via `globalStorageUri`. Auto-flushes on reconnect. Survives VS Code restarts. Cache count shown in status bar tooltip. +- **Workspace activity tracking**: per-file edits, lines added/removed, focus time, active file indicator. Grouped by workspace folder (project), sorted by activity. Top 10 files per project. +- **Terminal command tracking**: full command text, exit code, cwd, output streaming (via `execution.read()`), shell type detection (zsh/bash/fish/powershell/cmd/node/python), focus time per terminal, PID. Expandable command output in sidebar. +- **Terminal shell integration**: captures commands via `onDidStartTerminalShellExecution` / `onDidEndTerminalShellExecution` (VS Code 1.93+). CWD tracked via `onDidChangeTerminalShellIntegration`. +- **Zone tracking**: editor/terminal/panel time split with stacked color bar (blue/green/yellow). Layout chips showing tab count, editor groups, terminal count. +- **Auto EEG labeling**: every significant VS Code event auto-inserts a searchable label into EEG recordings with smart categorization (editing, debugging, git commits, AI assistance, meetings, errors, navigation, terminal commands, zone switches). +- **Inline label embedding**: auto-labels embedded immediately via fastembed for instant searchability (no idle reembed wait). +- **Command execution tracking**: 40+ VS Code commands tracked (go-to-definition, rename, find, format, fold, git, AI, debug, layout) with semantic categorization. +- **IntelliSense acceptance detection**: multi-char single-line insertions heuristically identified as autocomplete acceptances. +- **Clipboard tracking**: clipboard content changes polled every 5s with debounce. +- **Layout snapshots**: periodic (60s) capture of editor groups, visible editors, open tabs, terminal count sent to daemon. +- **Zone switch events**: editor/terminal/panel transitions sent to daemon with EEG focus snapshot. +- **File system watcher**: selective watching of package.json, Cargo.toml, go.mod, .git/HEAD for external change detection. +- **Environment context**: one-time capture of appHost, remoteName, shell, uiKind, language. +- **Info toggles**: every sidebar card has a `?` button explaining how metrics are calculated (flow score formula, struggle signals, energy bar, optimal hours, dev loops, terminal impact, context cost). +- **Open NeuroSkill button**: launches native app from sidebar (cross-platform: `open -a` macOS, `start` Windows, `xdg-open` Linux). Also in command palette. +- **Command palette → sidebar**: `Show Brain Status`, `Today's Report`, `Am I Stuck?`, `Best Time to Code` now open sidebar and scroll to relevant section instead of showing toast notifications. + +- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable insights in one call. +- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — "your tests fail 45% more when focus is low." +- **Hourly productivity**: churn + undo rate + avg focus by hour of day — "you write 3x more bugs after 2pm." +- **Context switch recovery**: focus level at editor/terminal/panel transitions — "switching to terminal costs 4 focus points." +- **AI tool impact**: Claude/Pi focus delta vs baseline — "Claude conversations drop focus by 8 points." +- **Focus by language**: avg EEG focus + undo rate per programming language — "best focus in Rust, worst in CSS." +- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day. +- **Tool focus impact**: avg focus when using each command category (docker, git, deploy, etc.). + +- **Widget accessibility and localization**. + +- **Analysis widgets (Optimal Hours, Daily Report, Weekly Trend)**. + +- **Biometric widgets (Heart Rate, Cognitive Load, EEG Band Power, Sleep)**. + +- **Brain Dashboard widget (medium)**. + +- **Calendar Mind State widget (large)**. + +- **Widget deep links (neuroskill:// URL scheme)**. + +- **Widget development infrastructure**. + +- **Core desktop widgets (Focus, Streak, Session, Break Timer)**. + +- **Interactive widget buttons (macOS 14+)**. + +- **Widget offline data caching**. + +- **Widget timeline reload on state changes**. + +- **zuna-rs 0.1.4**: updated from 0.1.3 with native MLX backend for EEG embedding inference. +- **`embed-zuna-mlx` feature**: new `ZunaMlxState` struct with `ZunaEncoder`. Adds `load_zuna_mlx()` and `encode_zuna_mlx()` functions matching the existing GPU/CPU variants. +- **Load priority**: when user selects Auto or MLX, encoder loading tries MLX first, then GPU f16, then GPU f32, then CPU. + +### Performance + +- **MLX vs GPU benchmarks** (Mac mini, Apple Silicon): + +| Dataset | Points | GPU (wgpu) | MLX | Speedup | +|---|---|---|---|---| +| Small | 200 | 120.9 s | 2.3 s | **51x** | +| Medium | 1,000 | 136.6 s | 7.1 s | **19x** | +| Large | 5,000 | 152.8 s | 23.8 s | **6.4x** | + +- **`open_readonly` for read-only handlers**: added `ActivityStore::open_readonly()` that skips 15+ ALTER TABLE migrations and opens in read-only mode. Switched 38+ HTTP handlers and 3 background tasks from `open` to `open_readonly`. +- **`PRAGMA busy_timeout=5000`**: added to `init_wal_pragmas` and `util::open_readonly` so all database connections (activity, labels, screenshots, EEG embeddings) wait up to 5 s on lock contention instead of failing immediately with SQLITE_BUSY. +- **Composite indexes**: added `(seen_at, file_path)` and `(seen_at, project)` indexes on `file_interactions` for queries that filter by time range and group by path or project. +- **Lock consolidation**: `productivity_score` reduced from 3 separate mutex acquisitions to 1 via internal `_q` query helpers. `weekly_digest` reduced from 12 to 4 locks. Introduced `daily_summary_q`, `context_switch_rate_q`, `get_focus_sessions_in_range_q` static methods that take `&Connection` directly. +- **`PRAGMA optimize` after pruning**: runs after hourly retention pruning to keep query planner statistics fresh. +- **Batch label lookup in interactive search**: replaced 15 per-epoch `get_labels_near` calls (each opening a new DB connection) with a single `get_labels_near_batch` call per text label, then in-memory filtering. Reduces search DB round-trips from ~28 to ~16. + +### Bugfixes + +- **Daemon-side event storage**: `git_commit`/`push`/`pull`/`checkout`/`stage`/`unstage`/`stash` events were received but never written to `build_events` (used only for EEG labels). Now persisted, with AI-assisted commits also recorded as `ai_events` (type `"ai_commit"`) and labelled `"git commit (AI)"`. +- **Completion-accepted events were dropped**: previously ignored by the daemon. Now stored as `ai_events` (type `"suggestion_accepted"`) and as edit chunks so they show up in code metrics and AI usage analytics. + +## Impact on analysis + +Brain analysis endpoints can now: +- Count human vs AI commits (`/brain/developer-insights`) +- Track AI suggestion acceptance rates (`/brain/ai-usage`) +- Include git activity in the activity timeline +- Weight human-authored code differently from AI output in focus/productivity scores + +## Files + +- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Event handler updates + +- **Schema migration**: ALTER TABLE for existing databases missing columns added across releases. Runs before DDL to handle all legacy schemas. +- **Retention pruning**: meetings, clipboard, and secondary_windows pruned alongside file interactions during hourly maintenance. + +- **Dismiss stale Dependabot alerts**: crossbeam-deque, crossbeam-utils, crossbeam-queue, memoffset, and glib alerts dismissed (already at patched versions or fixed in fork). + +- **CORS allow-methods now includes `PATCH`**: `crates/skill-daemon/src/main.rs` was advertising only `GET, POST, PUT, DELETE, OPTIONS`, so the browser preflight blocked `PATCH /v1/validation/config` from the Tauri webview. Added `Method::PATCH` to the list. + +- **Equal padding on screenshot edges**: viewport width was 360px while the body was 320px wide, leaving an asymmetric 40px right gutter. Matched viewport to body width so left and right gutters are now identical. + +- **Deadlock in daily-report endpoint**: `daily_brain_report` held the database mutex while calling `productivity_score`, which re-acquires the same mutex. Scoped the lock to drop before the nested call. +- **Deadlock in struggle-predict endpoint**: same pattern in `predict_struggle` — held mutex while calling `get_recent_files`. +- **`overall_focus` returned `-0.0`**: always wrapped in `Some` even with no EEG data. Now returns `null` when no focus samples exist. +- **`best_period` arbitrary without EEG**: picked the first SQL result when all `avg_focus` were `None`. Now returns empty string when no EEG data to compare. +- **`weekly_avg_deep_mins` returned `-0.0`**: divided by hardcoded 7 regardless of actual days. Now divides by actual day count, returns `0.0` when empty. +- **Notification text ugly without EEG**: daily report notification showed "Best: . Score: 25. Focus: 0." — now conditionally includes only available fields. +- **Brain endpoints return HTTP 200 on errors**: all 18 brain handlers silently returned `null` with 200 OK when the activity store was offline or a query failed. Refactored to use `run_query` helper that returns 503 (db_unavailable) or 500 (task_error) with structured `ApiError` JSON. + +- **Worker thread panic recovery with auto-restart**: all 4 activity worker threads (poller, input monitor, file watcher, clipboard monitor) now wrapped in `catch_unwind` via `spawn_resilient`. On panic, the worker logs the error and restarts after 5 s with freshly cloned `AppState` and `Arc`. +- **osascript timeout**: all `osascript` calls (active window poll, secondary windows, clipboard monitor) now use a 3-second timeout via `run_osascript` helper. Previously, a hung app could block the poller thread indefinitely, silently killing all activity tracking. +- **Daily report catch-up on late startup**: if the daemon starts after 18:00 local, the daily brain report fires on the first tick instead of waiting up to 1 hour for the hourly check. +- **WAL checkpoint on shutdown**: daemon now runs `PRAGMA optimize` on the activity database during graceful shutdown, ensuring the WAL is checkpointed and the database is clean for next start. +- **Missing WAL pragmas on EEG embedding store**: `day_store.rs` opened connections without `init_wal_pragmas`, missing both WAL mode and `busy_timeout`. +- **Retention pruning for new tables**: added `prune_terminal_commands`, `prune_ai_events`, `prune_zone_switches`, `prune_layout_snapshots` — wired into the hourly maintenance cycle so these tables don't grow unbounded. +- **TOCTOU race in calibration settings**: concurrent profile CRUD could lose writes. Added `modify_settings_blocking()` helper that runs under a global mutex on tokio's blocking thread pool — serializes read-modify-write without blocking the async runtime. + +- **Duration-averaged EEG**: `FileSnapshot` now samples EEG focus/mood every 5 s (at each edit-chunk tick) and writes the average to `file_interactions` at finalize, replacing the single snapshot captured at file-switch time. Falls back to the initial snapshot if no samples were collected (`COALESCE`). +- **EEG mood/focus sample count mismatch**: both `FileSnapshot` and `build_focus_sessions` used a single counter for focus and mood samples, inflating mood averages when they arrived independently. Split into separate `focus_count` and `mood_count`. +- **EEG ghost data after disconnect**: `latest_bands` was not cleared when an EEG device disconnected, causing stale focus/mood values to persist in reports and the activity pipeline. Now cleared on all 3 disconnect paths (idle timeout, stream end, explicit disconnect). + +- **InteractiveGraph3D event listener leaks**: `pointerdown` and `dblclick` listeners on the WebGL canvas were never removed in `onDestroy`. Extracted into named refs with cleanup. +- **UmapViewer3D event listener leaks**: 4 canvas event listeners (pointermove, pointerleave, pointerdown, pointerup) added as anonymous functions were never removed on unmount. Extracted into named module-level refs with removal in `onDestroy`. +- **ActivityTab brain polling leak**: `startBrainPolling()` was called on mount but `stopBrainPolling()` was never called on destroy, leaving a 30-second interval running after leaving the tab. +- **Brain polling stopped by peer component**: `stopBrainPolling()` unconditionally killed the interval even when other components still needed it. Added reference counting so polling only stops when the last consumer unmounts. +- **Memory leak in terminal_command_end labeling**: `Box::leak` was used to format non-zero exit codes in EEG auto-labeling, permanently leaking memory for every failed command. Replaced with owned `String`. +- **Screenshot encode failures untracked**: `encode_webp` failures (disk full, I/O error) silently continued without incrementing the `capture_errors` counter. Now counted so metrics reflect actual failures. +- **401 auto-retry**: daemon HTTP client now retries once with a fresh token on 401 (stale token after daemon restart) instead of failing immediately. + +- **Path traversal in `delete_session`**: `csv_path` parameter was passed unsanitized to `fs::remove_file`. Now validates the path is within `skill_dir` via canonicalize + starts_with. +- **Timing-vulnerable token comparison**: default bearer token checks in auth middleware used `==` (variable-time). Switched to `constant_time_eq` for both in-memory and on-disk token paths. +- **Unbounded CSV memory in EXG loader**: loading a CSV with millions of rows would allocate unlimited RAM. Added a 4M row cap (~4.3 hours at 256 Hz). + +- **Timezone-wrong period classification**: `daily_brain_report`, `hourly_edit_heatmap`, and `optimal_hours` used `seen_at % 86400` (UTC hour) instead of local hour. Daily report now uses `seen_at - day_start`; heatmap and optimal-hours accept a timezone offset computed server-side. +- **UTC midnight in all clients**: search page, CLI (`cmdActivity`, `cmdBrain`), VS Code extension, and Swift widget all computed `day_start` as UTC midnight instead of local midnight. Fixed to use `Date.setHours(0,0,0,0)` (JS/TS) and `Calendar.startOfDay` (Swift). +- **Widget empty daily-report body**: `DaemonClient.fetchDailyReport()` sent an empty POST body; the endpoint requires `dayStart`. Now sends local midnight. +- **Widget UTC sleep/calendar endpoints**: `fetchSleep()` and `fetchCalendarEvents()` used `% 86400` UTC approximations. Now use `Calendar.current` for correct local time boundaries. + +### Refactor + +- **Shared `DaemonClient` for VS Code extension**: extracted the repeated `fetch` + auth-token + port-discovery + 3 s timeout pattern into one class. All features now use `client.post(path, body)`. Returns `null` on failure (never throws), with `setToken()` for refresh on reconnect. + +## Before + +Every component (brain.ts, sidebar.ts, extension.ts) independently constructed fetch calls: +```typescript +const port = await discoverDaemonPort(config); +const base = `http://${config.daemonHost}:${port}/v1`; +const headers = { "Content-Type": "application/json" }; +if (token) headers["Authorization"] = `Bearer ${token}`; +const resp = await fetch(`${base}${path}`, { method: "POST", headers, body, signal: AbortSignal.timeout(3000) }); +``` + +## After + +```typescript +const client = new DaemonClient(config, token); +const result = await client.post("/brain/flow-state", { windowSecs: 300 }); +``` + +## Benefits + +- Single place to update auth, timeout, port discovery +- All 8 new features use the shared client +- `setToken()` method for token refresh on reconnect +- Returns `null` on any failure (never throws) — all features handle gracefully + +## Files + +- `src/daemon-client.ts` — DaemonClient class (new) + +- **Sidebar disclaimer test now accepts either localiser**: existing `vscode-sidebar-disclaimer.test.ts` updated so the `vscode.l10n.t("sidebar.disclaimer")` ↔ `tr("sidebar.disclaimer")` migration doesn't break the assertion. Both look up the same key. + +- **Conversations table + FTS5**: full-text search on all AI conversation messages (user/assistant/tool). Three search modes: FTS, fuzzy, structured. +- **EEG timeseries table**: periodic JSON snapshots of all brain metrics. Extensible — new metrics without schema changes. Join-at-query-time correlation with any event. +- **Embeddings store**: generic, multi-model. User prompts embedded via fastembed (local, no API credits). Can re-embed with different models. +- **Code context HNSW index**: separate from label index for code-specific semantic search. +- **Binary extraction**: terminal commands store raw binary name + args separately. Lazy categorization — 284 CLI tools recognized, rerun anytime. +- **EEG attachment**: live focus/mood from `latest_bands` injected at event storage time. +- **EEG timeseries worker**: writes full band powers to `eeg_timeseries` every 5s during recording. + +### Build + +- **CI for the VS Code extension** (`extensions/vscode/.github/workflows/`): two workflows live in the submodule repo (`vscode-neuroskill`). + - **`ci.yml`** — runs on every PR and `main` push. `npm ci` → `tsc` build → `vsce package` → uploads the `.vsix` as a 14-day artifact. No secrets, no publish. + - **`release.yml`** — fires two ways. *Tag push* (`git push --tags` after `npm version patch`) verifies the tag matches `package.json` and publishes. *Manual dispatch* with a `patch | minor | major | x.y.z` input bumps `package.json`, commits, tags, pushes, then publishes. Both paths run `npx @vscode/vsce publish` (uses `VSCE_PAT`) and `npx ovsx publish` (uses `OVSX_PAT`), then create a GitHub release with the `.vsix` attached and the latest `## ` block from `CHANGELOG.md` as release notes. +- **Skip-on-missing-secret**: if either `VSCE_PAT` or `OVSX_PAT` is unset, the corresponding publish step emits a `::warning::` and is skipped — letting you wire up Open VSX later without breaking the workflow. +- **Releasing section in the extension README**: documents the secret setup (Azure DevOps PAT, Open VSX token), the two release flows, and the one-time namespace claims (Marketplace publisher registration + `ovsx create-namespace`). + +- **macOS: link libusb statically into `skill-daemon`**: vendored libusb via `rusb`'s `vendored` feature in `crates/skill-devices`. The `antneuro` USB driver pulls in `rusb` → `libusb1-sys`, which by default uses `pkg-config` to find a system libusb-1.0.dylib. On the macOS-26 release runner that resolved to Homebrew's `/opt/homebrew/opt/libusb/lib/libusb-1.0.0.dylib`, so end users without Homebrew hit a launch-time `dyld: Library not loaded` error. Cargo feature unification now flips `libusb1-sys/vendored`, which compiles libusb from source and links `libusb-vendored.a` into the binary — verified by 105 `_libusb_*` text symbols present in the release binary and zero libusb references in `otool -L`. + +### CLI + +- **`neuroskill activity` expanded**: 19 subcommands — bands, window, input, windows, files, top-files, top-projects, languages, sessions, meetings, clipboard, builds, heatmap, switches, summary, score, digest, stale, timeline. +- **`neuroskill brain` added**: 14 subcommands — flow, load, load-files, recovery, optimal, fatigue, struggle, report, breaks, streak, task, stuck, interruptions, correlation. +- **`neuroskill vscode` added**: auto-build and install VS Code/VSCodium/Cursor extension from source on macOS, Linux, and Windows. + +### UI + +- **EEG focus timeline heatmap**: collapsible "Focus Timeline" sidebar section renders a ~280×36 px SVG sparkline of today's EEG focus (`/brain/eeg-range`, max 120 points), color-graded green/yellow/red, with hour labels and file-name annotations at peaks/valleys (merged from `/activity/timeline`). Toggle via `neuroskill.eegHeatmap`. + +## What you see + +In the NeuroSkill sidebar, a collapsible "Focus Timeline" section shows: + +- A ~280px wide, ~36px tall SVG sparkline +- Color gradient: green (>70 focus), yellow (40-70), red (<40) +- Hour labels along the bottom (0:00, 3:00, 6:00, ...) +- File names annotated at focus peaks and valleys + +## Data sources + +| Data | API Endpoint | +|------|-------------| +| EEG time-series | `/brain/eeg-range` (today, max 120 points) | +| File context | `/activity/timeline` (today, last 200 events) | + +The heatmap merges EEG data points with the closest timeline events to show which files correspond to focus peaks and dips. + +## Settings + +`neuroskill.eegHeatmap` (default: `true`) — Show/hide the heatmap in the sidebar. + +## Files + +- `src/sidebar.ts` — `_fetchHeatmap()` and `_renderHeatmap()` methods + +- **Brain status bar**: persistent bottom bar in Tauri app showing flow state, fatigue, streak, edit velocity, and focus score. +- **Brain dashboard card**: flow/fatigue/streak + focus progress bar on the EEG dashboard alongside existing BrainStateScores. +- **Brain indicators in ActivityTab**: flow state card (green), fatigue alert card (amber), and streak card (violet) at top. + +- **Tauri Brain Insights section** in Activity tab: test failure rates by focus level, focus by language bars, AI impact delta, tool focus grid, hourly productivity chart. +- **Tauri EEG focus inline**: terminal commands and conversation messages show EEG focus score (green/yellow/red) at the moment they occurred. +- **VS Code Brain Insights card**: test failure by focus chips, focus by language bars, tool impact chips. Only shown when EEG data is available. +- **VS Code Struggle card**: hidden when no EEG recording (focus is EEG-only, not activity-based). +- **VS Code Flow gauge label**: shows "focus" with EEG, "activity" without — no false claim of brain measurement. + +- **Tauri AI Conversations section** in Activity tab: timestamped message thread with role icons (user/assistant/tool), app name, EEG focus score, scrollable. +- **VS Code Conversations card**: timestamped messages with role icons, app badge, collapsible. +- **VS Code AI Usage card**: Copilot/Codeium acceptance rate bar, suggestion count, source breakdown chips. + +- **Search mode dropdown**: replaced 4-tab bar with a styled dropdown supporting 7 search modes — Interactive, EEG, Text, Images, Code, Meetings, Brain. +- **Diamond EEG nodes**: EEG epochs rendered as faceted octahedrons (diamonds) in the 3D search graph. Meeting nodes rendered as tetrahedrons (pyramids). Visual distinction by data type. +- **New search modes**: Code (file activity + brain state), Meetings (meeting events + recovery), Brain (flow/fatigue/struggle insights). +- **Terminal commands in Cmd-K**: added 4 terminal commands to the command palette — Recent Terminal Commands, Terminal Focus Impact, Context Switch Cost, Dev Loops. Translated labels in all 9 locales. + +- **Configurable alerts**: `neuroskill.alerts.focusLow` (warn when EEG focus drops below threshold), `neuroskill.alerts.continuousWorkMins` (warn after N minutes continuous work). +- **Daily digest notification**: at configurable time (default 17:30), shows productivity score + stats. "Full Report" button opens detail panel. +- **Full report panel**: `Cmd+Shift+R` opens full-width webview with same data as sidebar. +- **Keyboard shortcuts**: `Cmd+Shift+N` (toggle sidebar), `Cmd+Shift+R` (full report), `Cmd+Shift+Alt+N` (reconnect). +- **Open NeuroSkill button**: launches native app (cross-platform). +- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk. Auto-flushes on reconnect. + +- **Spacing pass on the validation tab and modals**: every card uses `flex flex-col gap-5 p-6` instead of the old `space-y-4` (which provided no padding override). Conditional sub-settings under a channel toggle are now indented under a left border (`ml-1 border-l-2 border-border pl-5`) so the parent/child relationship reads visually. Modals padded to `p-8` with `gap-3` button rows. TLX sliders gained `mt-1` lift off the description and `uppercase tracking-wide` low/high legend labels. + +- **Design tokens** (`tokens.css`): 30+ CSS custom properties — palette, surfaces, backgrounds, borders, semantic, typography. Zero inline rgba() in sidebar. +- **7 reusable Svelte components**: Card, MetricRow, Chevron, ProgressBar, Gauge, Badge, Callout. +- **Timestamp compliance**: 25 tests verifying UTC in data layer, local conversion only in UI. + +- **Theme-aware sidebar screenshots in the README**: every screenshot now ships in two variants (`*-dark.png`, `*-light.png`) and is embedded via a `` element with `prefers-color-scheme` sources. GitHub serves the variant matching the reader's OS theme; the marketplace gets the dark fallback. Six states captured: in-flow, stuck, fatigued, low-focus / off-peak, daemon-disconnected, status-bar strip. +- **Sidebar mock library + Playwright generator**: HTML stubs in `extensions/vscode/media/preview/` render the sidebar webview offline (no daemon required). A shared `_styles.css` drives both themes via a `:root.light` override; `npm run screenshots` (Playwright) toggles `` between captures and writes 12 PNGs to `media/screenshots/`. +- **Marketplace README pipeline**: `scripts/build-marketplace-readme.mjs` rewrites every relative `media/...` path (including ``, which `vsce` doesn't touch) to absolute GitHub raw URLs at package time. The source `README.md` keeps relative paths for GitHub + offline viewing; the generated `.marketplace.readme.md` is what `vsce package --readme-path` ships. + +- **Sidebar webview now renders correctly in light themes** (`extensions/vscode/src/sidebar.ts`). Three theme-fragile spots fixed: + - `.metric-card` background switched from `--vscode-sideBar-background` (which is the same colour as the surrounding sidebar — cards collapsed into a faint border in light mode) to `--vscode-editorWidget-background`, with `--vscode-input-background` and a translucent grey as cascading fallbacks. + - `.ai-bar` track switched from `--vscode-panel-border` to `--vscode-progressBar-background` so the empty bar is visible against a white background. + - Row-divider opacity bumped from 0.08 to 0.18; added `:last-child { border-bottom: none }` on commit and AI-metric rows so the last row doesn't double up against the disclaimer footer. +- **State colours stay hard-coded** — red ring for stuck, green for flow, amber for warning. They're semantic, not chrome, and they read fine on both themes. + +### Server + +- **`POST /v1/activity/shell-command`**: receives commands from shell hooks with command text, cwd, shell type, exit code. +- **`POST /v1/brain/terminal-commands`**: query recent commands with exit codes, durations, categories, EEG correlation. +- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category. +- **`POST /v1/brain/binary-stats`**: usage frequency per binary for discovering uncategorized tools. +- **`POST /v1/brain/recategorize-commands`**: rerun categorization rules on all historical commands. +- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end. +- **Zone switch events**: editor/terminal/panel transitions stored with EEG focus snapshot. `POST /v1/brain/context-cost` returns focus at each transition type. +- **Layout snapshots**: periodic (60s) tab/group/terminal counts from VS Code. + +- **`/v1/validation/*` HTTP endpoints** (axum): `GET /config`, `PATCH /config` (recursive JSON merge for partial updates), `POST /snooze`, `POST /disable-today`, `GET /should-prompt` (returns the daemon's prompt decision; logs the fire so the rate-limiter sees it), `POST /kss`, `POST /tlx`, `POST /pvt`, `POST /close-prompt`, `GET /results`, `GET /fatigue-index` (live `(α+θ)/β` from the latest band snapshot). +- **`AppState.validation_runtime`** (`crates/skill-daemon-state/src/state.rs`): new `Arc>` field holding ephemeral snooze/disable-today state. Resets on daemon restart by design — a crash loop shouldn't keep prompts suppressed forever. + +- **`terminal_commands` table**: shell command text, cwd, exit code, duration, auto-categorized (50+ patterns: build/test/run/git/docker/deploy/install/navigate/debug/network/other), EEG focus at start and end for delta calculation. +- **`dev_loops` table**: edit→build/test cycle tracking with iteration count, pass/fail rate, avg cycle time, focus trend (rising/falling/stable). +- **`zone_switches` table**: editor/terminal/panel transitions with EEG focus snapshot at moment of switch. +- **`layout_snapshots` table**: periodic tab/group/terminal counts from VS Code. +- **Event handler**: new match arms for `terminal_command_start`, `terminal_command_end`, `zone_switch`, `layout_snapshot` in `activity_vscode_events_impl()`. +- **Command categorizer**: `categorize_command()` with 50+ patterns across build, test, run, git, docker, deploy, install, navigate, debug, network. +- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category — shows how builds, tests, git, docker affect brain state. +- **`POST /v1/brain/context-cost`**: focus level at each zone transition type with switch counts. +- **`POST /v1/brain/dev-loops`**: edit-build-test cycle detection with iterations, pass rate, cycle time, focus trend. +- **`POST /v1/brain/terminal-commands`**: recent commands with exit codes, durations, categories, EEG correlation. +- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages". +- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl→infrastructure (0.8), deploy→deploying (0.85), git dominating→git_management (0.7), test→testing (0.85), debug→debugging (0.9). +- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end, `"switched to terminal"` on zone changes. +- **AI events table**: `ai_events` SQLite table tracking suggestion shown/accepted/rejected and chat sessions with source attribution. +- **OS-wide shell hooks**: `scripts/shell-hooks/` with preexec/precmd hooks for zsh, bash, fish, PowerShell. Sends every command to daemon via background curl. No delay to prompt. +- **Shell hook daemon endpoints**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to `~/.skill/shell-hooks/` and appends to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check. +- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). +- **`neuroskill brain` new subactions**: `terminal-impact`, `context-cost`, `dev-loops`. +- **`neuroskill activity` new subaction**: `terminal-commands`. +- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons, health indicators (green/yellow/red), recent commands preview, "How it works" documentation. +- **`neuroskill vscode` CLI**: auto-install extension to VS Code, VSCodium, or Cursor on macOS, Linux, and Windows. +- **Meeting nodes in search graph**: meetings appear as amber nodes in the interactive 3D search graph linked by `meeting_prox` edges. + +### i18n + +- Activity dashboard, clipboard, file history, session replay, weekly report, brain status, timeline, and code-brain correlation translations for all 9 locales. + +- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +- Added `umapSettings.backend`, `umapSettings.backendDesc`, `umapSettings.precision`, `umapSettings.precisionDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +- Added `dnd.grayscale` and `dnd.grayscaleDesc` translations to all 9 locales. + +- Search mode labels (Code, Meetings, Brain) translated in all 9 locales. +- Terminal command palette entries translated in all 9 locales. + +- **New `validation` namespace** in all 9 languages (`src/lib/i18n/{de,en,es,fr,he,ja,ko,uk,zh}/validation.ts`): ~95 keys in English (full coverage), ~55 in each other language for the user-visible strings (long-tail descriptions fall back via the runtime `t()` chain). `index.ts` barrel exports updated for every language. + +- **22 new `validation.*` keys per bundle** in all 9 languages (`bundle.l10n.{de,en,es,fr,he,ja,ko,uk,zh-cn}.json`): KSS prompt + 9 score labels with translated Karolinska wording, TLX/PVT prompts, all four escape-hatch labels, the disabled-permanently acknowledgement. +- **Two new `cmd.*` keys** for the validation commands in `package.nls.json`. + +- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +### Docs + +- VS Code extension design plan at `docs/vscode-extension.md`. +- `neuroskill-activity` skill documentation with 18 activity + 24 brain subcommands, terminal integration, shell hook reference, command categorization table. +- Updated `neuroskill-dnd` skill with grayscale mode. +- Updated `neuroskill/README.md` with terminal, brain awareness, and VS Code extension features. +- Updated `skills/SKILL.md` index with terminal tracking skill reference. + +- **VS Code extension README rewritten for skeptical developers**: scientific framing throughout — every derived metric (focus score, flow detector, deep-work minute, struggle predictor, optimal hours, fatigue) now ships with its formula, hyperparameters, and a "what can go wrong" line. Added an explicit *What this is, and isn't* section, a *Validation status* table tracking each metric's evidence level (production / pilot / heuristic / descriptive-only), and a *Validation roadmap* describing the planned KSS / NASA-TLX / PVT / EEG-fatigue calibration channels. Replaced the marketing pain-points table with a per-API signal taxonomy and an architecture paragraph. +- **References section with verified citations**: nine entries with DOIs cross-checked via CrossRef and `doi.org` redirects — Csikszentmihalyi 1990 (flow), Klimesch 1999 (α/θ oscillations), Lubar 1991 (θ/β attention ratio), Barry et al. 2005 (caffeine confound), Newport 2016 (deep work), Roenneberg et al. 2003 (chronotypes), Hart & Staveland 1988 + Hart 2006 (NASA-TLX), Jap et al. 2009 (EEG fatigue index). Each reference linked back to the metric it grounds. +- **Repository metadata pointed at the right repo**: discovered `extensions/vscode/` is a git submodule of `vscode-neuroskill`, not a subdirectory of the monorepo. Reverted `repository.url`, `bugs.url`, `homepage`, `qna` to the submodule's actual repo so the marketplace links resolve. + +### Dependencies + +- **burn-mlx**: added `burn-mlx` from git (`eidola-ai/burn-mlx`, branch `burn-0-20`) as optional dependency in `skill-router` and `skill-daemon`. Workspace `[patch.crates-io]` redirects all `burn-mlx` references to the git version (crates.io 0.1.2 targets burn 0.16; project uses burn 0.20). +- **macOS deployment target**: bumped from 10.15 to 14.0 in `.cargo/config.toml` for Metal 3.0 `simdgroup_matrix` intrinsics required by MLX. Still satisfies llama.cpp's `std::filesystem` (available since 10.15). +- **half 2.4**: added to `skill-router` for GPU f16 precision backend type (`CubeBackend`). + +- **Fix rand 0.7.3 vulnerability**: vendored `phf_generator 0.8.0` with rand bumped from 0.7 to 0.8, eliminating the vulnerable transitive dependency. +- **Remove atty**: migrated `iroh_test_client` from `structopt` (clap v2) to `clap v4` derive, dropping the unmaintained `atty` crate. +- **Update llama-cpp-4 to 0.2.50**: upstream llama.cpp fixes including `common_*` symbol renames. +- **Update kittentts to 0.4.1**: TTS engine update. +- **Add grayscale 0.0.1**: macOS grayscale display control for DND mode. + +## [0.0.130-rc.10] — 2026-05-02 + +### Features + +- fix windows ci + +## [0.0.130-rc.11] — 2026-05-02 + +### Features + +- 1. GPU f16 SHADER_F16 panic → `catch_unwind` in worker.rs + +## [0.0.130-rc.12] — 2026-05-03 + +### Features + +- translations +- 1. Settings tab font-size lint rule (scripts/check-settings-font-sizes.js) +- The baseline doubles as a punch list — top offenders are ActivityTab (95), ValidationTab (36), and TerminalSessionsCard (27). +- 2. Tray hint when auto-update is OFF and an update is detected +- auto-update + update RC settings + +## [0.0.130-rc.13] — 2026-05-04 + +### Features + +- deps(npm): bump the npm-all group with 10 updates +- Bumps the npm-all group with 10 updates: +- | Package | From | To | +- Updates `@tauri-apps/api` from 2.10.1 to 2.11.0 +- Updates `@tauri-apps/plugin-opener` from 2.5.3 to 2.5.4 +- Updates `@threlte/core` from 8.5.9 to 8.5.11 +- Updates `@threlte/extras` from 9.14.9 to 9.15.1 +- Updates `bits-ui` from 2.18.0 to 2.18.1 +- Updates `marked` from 18.0.2 to 18.0.3 +- Updates `@biomejs/biome` from 2.4.13 to 2.4.14 +- Updates `@sveltejs/kit` from 2.58.0 to 2.59.0 +- Updates `@tauri-apps/cli` from 2.10.1 to 2.11.0 +- Updates `svelte-check` from 4.4.6 to 4.4.7 +- --- +- Signed-off-by: dependabot[bot] + +## [0.0.130-rc.14] — 2026-05-05 + +### Features + +- **ECHT (Endpoint-Corrected Hilbert Transform)**: new EEG metric measuring alpha-band rhythmicity (0–1) via a causal complex-Morlet kernel. Phase estimates remain valid at the buffer edge, where FFT-based Hilbert breaks down. Surfaced in the live dashboard, session detail view, comparison view, recordings (CSV/Parquet), and history aggregates. Reference: Schreglmann et al., *Nat. Commun.* 12:363 (2021), [doi:10.1038/s41467-020-20581-7](https://doi.org/10.1038/s41467-020-20581-7). + +### i18n + +- **ECHT translations**: added `sd.echt`, `compare.echt`, `dashboard.echt`, and `tip.echt` for all 9 supported locales (en, de, es, fr, he, ja, ko, uk, zh). + +## [0.0.130-rc.15] — 2026-05-05 + +### Features + +- normalized fonts + +## [0.0.130-rc.16] — 2026-05-05 + +### Features + +- Minor updates and improvements + +## [0.0.130-rc.17] — 2026-05-05 + +### Features + +- address ZUNA GPU f16 fallback failure and embedding search timestamp mismatch + +## [0.0.130-rc.18] — 2026-05-06 + +### Performance + +- **Idle re-embed throttle default**: bumped from 10 ms to 200 ms between epochs. The previous value drove the daemon to ~100% CPU on machines without a fast GPU whenever the device was idle. A migration in `load_settings` promotes any existing `idle_reembed_throttle_ms == 10` to 200 and rewrites the file, so users who hit the bug get fixed on next launch. +- **Adaptive scanner cadence**: the auto-started device scanner stays at 5 s while devices are paired or being seen, then backs off to a 30 s tick after 5 minutes of empty scans with no paired devices. Any new discovery (or BLE/USB activity) snaps it back to fast cadence. Previously every transport — USB serial, BLE cache, Cortex, NeuroField, BrainBit, g.tec, ANT Neuro, BrainMaster — was probed forever every 5 s even on installs with no hardware. +- **Active-window poll** slowed from 1 s to 3 s. Still snappy enough for app-switch tracking; ~3× fewer wakeups for the platform window probe (Accessibility on macOS, X11/Wayland calls on Linux). +- **macOS clipboard monitor** now reads everything natively via `objc2-app-kit`: `NSPasteboard.changeCount` for the change gate, `NSPasteboard.types`/`dataForType:` for content classification and size, and `dataForType:NSPasteboardTypePNG` for clipboard image capture. Removes every `osascript` subprocess fork and the Apple Events permission prompt that used to come with it. Steady-state and copy events both run inside the daemon process. + +- **Idle re-embed throttle migration logging**: `load_settings` now emits a `tracing::info!` line when it promotes a legacy `idle_reembed_throttle_ms == 10` to 200, so support can confirm the migration ran from the daemon log without diffing the settings file. + +### UI + +- **Daemon Background Activity panel** in Settings → Settings tab: lists every recurring daemon task with a one-line description, a `Why:` explanation, interval, cost class, and a live "running"/"idle" badge plus `last ran Ns ago · took X ms · N ticks`. Subscribes to the `activity-state` WebSocket event for live updates and falls back to a 30 s safety-net `/v1/activity` poll. Users can decide which trackers to disable based on what each one is actually for and how active it currently is. + +### Server + +- **`GET /v1/activity`**: new endpoint returns a manifest of every recurring background task the daemon runs, with `name`, `does`, `why`, `interval_secs`, `cost`, `user_toggleable`, plus live state (running flag, idle countdown) and `heartbeat` (`last_tick_unix_ms`, `last_duration_ms`, `tick_count`) read from a central registry on `AppState`. So users who notice CPU usage can see — and challenge — exactly which workers are active rather than guessing. +- **Background task registry + `activity-state` event**: `AppState::record_task_heartbeat(id, duration_ms)` is called once per tick by `device-scanner`, `status-monitor`, `idle-reembed`, `active-window-poll`, `clipboard-monitor`, `tty-embedder`, `reconnect`, and `skills-sync`. Each call updates the registry and (time-throttled per task to one broadcast every 5s, so a 100ms loop wouldn't flood the bus) broadcasts an `activity-state` WebSocket event with the heartbeat payload, so connected clients update without polling. Adding a new background loop without registering its id surfaces as a static row with a zeroed heartbeat — a built-in drift signal. The `idle-reembed` heartbeat additionally fires inside the embed-progress event consumer, so the panel reflects real per-batch wall-clock time rather than the outer 10s polling cadence. + +### i18n + +- Translated all `daemonActivity.*` keys (title, intro, loading, running, idle, eventDriven, whyPrefix, costLow/Medium/High, never, lastRanSecondsAgo / MinutesAgo / HoursAgo, tickDuration, tickCount) into all 9 locales: `en`, `de`, `es`, `fr`, `he`, `ja`, `ko`, `uk`, `zh`. Strings are now idiomatic (e.g. ES "carga baja" instead of "coste bajo", JA "実行回数: {n}" instead of "{n} 回実行", FR "il y a {n} s" instead of "{n} ms écoulées") and avoid singular/plural mismatches by using register-neutral phrasings ("Cycles : {n}" rather than "{n} exécutions"). + +## [0.0.130-rc.19] — 2026-05-06 + +### Features + +- cargo deny +- fix tty + +## [0.0.130-rc.2] — 2026-04-29 + +### Features + +- fix win/linux + +## [0.0.130-rc.20] — 2026-05-07 + +### Features + +- fix --deep signature + +## [0.0.130-rc.21] — 2026-05-08 + +### Features + +- deps(npm): bump the npm-all group with 10 updates (#60) +- Bumps the npm-all group with 10 updates: +- | Package | From | To | +- Updates `@tauri-apps/api` from 2.10.1 to 2.11.0 +- Updates `@tauri-apps/plugin-opener` from 2.5.3 to 2.5.4 +- Updates `@threlte/core` from 8.5.9 to 8.5.11 +- Updates `@threlte/extras` from 9.14.9 to 9.15.1 +- Updates `bits-ui` from 2.18.0 to 2.18.1 +- Updates `marked` from 18.0.2 to 18.0.3 +- Updates `@biomejs/biome` from 2.4.13 to 2.4.14 +- Updates `@sveltejs/kit` from 2.58.0 to 2.59.0 +- Updates `@tauri-apps/cli` from 2.10.1 to 2.11.0 +- Updates `svelte-check` from 4.4.6 to 4.4.7 +- --- +- Signed-off-by: dependabot[bot] + +## [0.0.130-rc.22] — 2026-05-08 + +### Features + +- updated iroh + +## [0.0.130-rc.23] — 2026-05-09 + +### Features + +- upgraded llama.cpp and added mtp + +## [0.0.130-rc.24] — 2026-05-09 + +### Features + +- fixed biome + +## [0.0.130-rc.25] — 2026-05-10 + +### Features + +- fixed sscache + cmake + +## [0.0.130-rc.26] — 2026-05-16 + +### Performance + +- **Local-only `hotpath` profiling for skill-router**: added optional `hotpath = "0.16"` dep behind three opt-in features (`hotpath`, `hotpath-cpu`, `hotpath-alloc`) so the default build pulls nothing extra. Annotated the suspected UMAP hot paths (`load_embeddings_range`, `load_labels_range`, `analyze_umap_points`, `fit_umap_gpu`, `fit_umap_mlx`, `umap_compute_inner`) with `#[cfg_attr(feature = "hotpath", hotpath::measure)]` — zero-cost when the feature is off. New `crates/skill-router/examples/umap_hotpath.rs` runner uses `#[hotpath::main]` to seed 500+500 synthetic 32-dim embeddings and print a per-function timing table on exit. Run with `cargo run -p skill-router --release --example umap_hotpath --features='gpu,hotpath'` (add `hotpath-alloc` for allocation tracking; swap `gpu` for `mlx` on Apple). First run on Apple M4 Pro confirmed 99.37% of UMAP wall-clock is inside `fit_umap_gpu` (44.96s of 45.24s on 1000×32-dim points); I/O and post-processing are five orders of magnitude smaller — useful signal for where *not* to optimize. + +### Bugfixes + +- **Fix daemon WS command dispatch starvation under event load**: the `/v1/events` handler ran `socket.send` (broadcast events) and `socket.recv` (incoming commands) in the same `tokio::select!` arm-set. With a Muse @ 256 Hz producing ~300–500 frames/sec across `EegSample`/`EegBands`/`ImuSample`/`PpgSample`/`SignalQuality`, the event arm won repeatedly, `sender.send().await` kept the task busy filling the kernel TCP buffer, and incoming commands timed out client-side at 15s. The smoke test hit this on every WS command. Restructured `handle_ws`: split the socket into `(sender, receiver)` halves; sender task drains a two-channel priority queue (responses via `biased` select, then events) with per-command dispatch spawned so slow handlers (`umap`, `sessions`) can't block the loop. High-rate event types are gated behind a per-connection subscribed set (default: none) — clients opt in via `{command:"subscribe",events:["EegSample",...]}` or `events:["*"]`; the neuroskill UI (`src/lib/daemon/ws.ts`) and neuroloop CLI auto-subscribe to the types they consume. Two regression tests (`ws_command_responds_under_event_flood`, `ws_filters_high_rate_events_by_default`) lock in the priority-queue invariant + default-filter behavior. + +- **Fix `interactive_search` hang on empty query**: with `query=""` the SQL `text LIKE '%' || '' || '%'` matched every label in `labels.sqlite`, then looped `search_embeddings_in_range(±10 minutes)` across every daily DB — 30s+ before the test harness gave up. The daemon now short-circuits to `{"ok":false,"error":"empty query"}` when the query is empty or whitespace-only. + +- **Fix smoke-test port discovery latching onto VS Code / dev-tool ports**: `test.ts`'s mDNS-fallback used `pgrep -if 'skill'`, which matched any process whose command line contained the substring "skill" — including VS Code Helpers running in `/Users/Shared/skill/...` workspaces. Once a wrong port was picked, `testWs()`'s bare-WebSocket handshake accepted it (VS Code, Vite HMR, etc. all accept WS upgrades), and every command then timed out at 15s, burning the entire 180s smoke budget. Tightened the pgrep regex to `(^|/)skill-daemon($|\s)|target/(debug|release)/skill($|\s)`, swapped `testWs()` to use the daemon's `DaemonStarted` welcome envelope as a protocol discriminator, and moved auth-token loading ahead of discovery so the probe can authenticate. + +### LLM + +- **MTP (Multi-Token Prediction) speculative decoding**: wired the upstream MTP API from `llama-cpp-4` 0.2.56 into the text-only generation path. When the active model is catalog-flagged `mtp: true` (e.g. the `froggeric/Qwen3.6-27B-MTP-GGUF` family) and the user sets `mtp_draft_count > 0`, the actor builds the target context with `with_n_rs_seq` so partial KV rollback works on hybrid/recurrent models (Qwen3.6 M-RoPE), runs a one-shot draft-context smoke check at load time (downgrades to the standard path on failure), and per-request constructs a `LlamaContextType::Mtp` draft context plus `MtpSession` to drive the full `draft → verify-batch → match-prefix → KV-rollback → accept` loop. Acceptance rate is logged per request. Vision (mtmd) requests stay on the non-MTP path. Verified end-to-end against `Qwen3.6-27B-IQ2_M-mtp.gguf` on Apple M4 Pro (3/3 drafts accepted on a short greedy prompt) via the new `tests/llm_mtp_e2e.rs` integration test, which skips gracefully when no MTP-capable GGUF is cached. + +### Dependencies + +- **Update llama-cpp-4 to 0.2.56**: bumped `llama-cpp-4` and `llama-cpp-sys-4` from 0.2.54 to 0.2.56, picking up upstream llama.cpp `64b38b561` (May 2026) which now ships MTP support natively (PR ggml-org/llama.cpp#22673). Breaking changes in the fork: the in-tree MTP patch is gone, so the `mtp` Cargo feature, `LlamaContext::set_mtp`, and `LlamaModelParams::with_override_arch` no longer exist. Dropped `"mtp"` from the metal/vulkan dependency feature lists in `skill-llm/Cargo.toml` and removed the dangling `llm-mtp` workspace feature (no downstream consumer). The new upstream API (`LlamaContextType::Mtp`, `with_ctx_type`, `with_n_rs_seq`, `llama_cpp_4::mtp::MtpSession`) is wired separately in the MTP speculative-decoding feature. + +## [0.0.130-rc.27] — 2026-05-17 + +### Build + +- **Fix release retry: cargo failures inside `run_cmd` were silently ignored**: `release-mac.yml`, `release-linux.yml`, and `release-windows.yml` call `run_cmd` via `if ! run_cmd; then`, which inhibits `set -e` inside the function body. A failing `cargo build -p skill-daemon` (e.g. link error against stale prebuilt llama libs) would silently continue to the next `cargo build`, the function would return 0, and the prebuilt→source-build fallback would never fire — leaving the assemble/package step to fail later with a confusing "missing daemon binary" error. Added explicit `|| return $?` after each cargo invocation so failures propagate regardless of bash's `set -e` inhibition rules. + +### Dependencies + +- **Bump llama-cpp-4 to 0.2.57**: bumped `llama-cpp-4` and `llama-cpp-sys-4` from 0.2.56 to 0.2.57, picking up the Windows MSVC bindgen fix (the `LLAMA_CONTEXT_TYPE_*` constants are `i32` on MSVC but the `LlamaContextType` enum is `#[repr(u32)]`, which broke the Windows release build). Pinned `LLAMA_PREBUILT_TAG` in `scripts/ci.mjs` to `v0.2.57` so the prebuilt llama libs ship the same MTP symbols (`mtp_session_new`, `mtp_session_draft`, etc.) the crate now expects — the previous `0.2.46` pin caused undefined-symbol link failures for `skill-daemon` on macOS and Linux after the 0.2.56 MTP upgrade. + +## [0.0.130-rc.28] — 2026-05-19 + +### Features + +- **Label index benchmark validation**: add side-by-side HNSW and TurboQuant label search benchmarks with top-result agreement, top-k overlap, and cosine-distance delta checks so users can verify result proximity before switching backends. + +- **TurboQuant label index backend**: add TurboQuant as an alternative label search backend alongside HNSW, while keeping HNSW as the default and maintaining both indexes during rebuilds and incremental label inserts. + +### UI + +- **Configurable label search backend**: add Settings controls for choosing between HNSW and TurboQuant, showing index counts, rebuilding indexes, and persisting the selected backend. + +## [0.0.130-rc.29] — 2026-05-19 + +### Features + +- Minor updates and improvements + +## [0.0.130-rc.3] — 2026-04-29 + +### Features + +- fixed issues with the metrics, keychain access (3 -> 1 + on demand), windows CI +- umap e2e + +## [0.0.130-rc.30] — 2026-05-19 + +### Features + +- turboquant index + +## [0.0.130-rc.31] — 2026-05-20 + +### Features + +- llama-cpp-rs@0.3.0 + +## [0.0.130-rc.4] — 2026-04-29 + +### Features + +- Minor updates and improvements + +## [0.0.130-rc.5] — 2026-04-30 + +### Features + +- fix vulkan cache on windows ci + +## [0.0.130-rc.6] — 2026-04-30 + +### Features + +- Minor updates and improvements + +## [0.0.130-rc.7] — 2026-05-02 + +### Features + +- Replace per-app AppleScript window tracking (which triggered a macOS TCC Automation dialog for every new foreground app) with native Accessibility API (AXUIElement) + CoreGraphics calls that require only a single one-time "Accessibility" permission for NeuroSkill. + +## [0.0.130-rc.8] — 2026-05-02 + +### Features + +- Minor updates and improvements + +## [0.0.130-rc.9] — 2026-05-02 + +### Features + +- Minor updates and improvements + +## [0.0.131-rc.2] — 2026-05-31 + +### Features + +- Minor updates and improvements + +## [0.0.131-rc.3] — 2026-06-01 + +### Features + +- fix release ci + +## [0.0.131-rc.4] — 2026-06-01 + +### Features + +- fixed windows and linux builds + +## [0.0.131-rc.5] — 2026-06-10 + +### Features + +- upgraded to rlx 0.2.5 +- upgrade to rlx 0.2.4 + +## [0.0.131-rc.6] — 2026-06-10 + +### Features + +- Minor updates and improvements diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8c20f5f..4e7388fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,10 @@ npm run tauri:build `npm run setup` auto-detects your platform and installs everything needed (protobuf, OpenMP, GNU ar, sccache, etc.). Pass `--yes` to skip prompts. See also `npm run setup:build-cache` and `npm run setup:llama-prebuilt` -for optional build acceleration. +for optional build acceleration, and `npm run setup:rlx` for the optional +[RLX](https://github.com/MIT-RLX/rlx) sibling checkout (required for CI and +for `llm-rlx` / `text-embeddings-rlx` features). Details: +[docs/DEVELOPMENT.md#rlx-optional-path-dependency](docs/DEVELOPMENT.md#rlx-optional-path-dependency). ## Project Structure diff --git a/Cargo.lock b/Cargo.lock index 83aecfee..1798aedd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,15 +8,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "415ed64958754dbe991900f3940677e6a7eefb4d7367afd70d642677b0c7d19d" -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -97,7 +88,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" dependencies = [ - "equator", + "equator 0.4.2", ] [[package]] @@ -137,7 +128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "libc", ] @@ -250,12 +241,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "ar_archive_writer" +name = "approx" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ - "object", + "object 0.37.3", ] [[package]] @@ -267,6 +267,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -292,9 +301,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow-array" -version = "58.1.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772bd34cacdda8baec9418d80d23d0fb4d50ef0735685bd45158b83dfeb6e62d" +checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e" dependencies = [ "ahash", "arrow-buffer", @@ -302,7 +311,7 @@ dependencies = [ "arrow-schema", "chrono", "half", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "num-complex", "num-integer", "num-traits", @@ -310,9 +319,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "58.1.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898f4cf1e9598fdb77f356fdf2134feedfd0ee8d5a4e0a5f573e7d0aec16baa4" +checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0" dependencies = [ "bytes", "half", @@ -322,9 +331,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "58.1.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d10beeab2b1c3bb0b53a00f7c944a178b622173a5c7bcabc3cb45d90238df4" +checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0" dependencies = [ "arrow-buffer", "arrow-schema", @@ -335,29 +344,29 @@ dependencies = [ [[package]] name = "arrow-ipc" -version = "58.1.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "609a441080e338147a84e8e6904b6da482cefb957c5cdc0f3398872f69a315d0" +checksum = "238438f0834483703d88896db6fe5a7138b2230debc31b34c0336c2996e3c64f" dependencies = [ "arrow-array", "arrow-buffer", "arrow-data", "arrow-schema", "arrow-select", - "flatbuffers 25.12.19", + "flatbuffers", ] [[package]] name = "arrow-schema" -version = "58.1.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c30a1365d7a7dc50cc847e54154e6af49e4c4b0fddc9f607b687f29212082743" +checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" [[package]] name = "arrow-select" -version = "58.1.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78694888660a9e8ac949853db393af2a8b8fc82c19ce333132dfa2e72cc1a7fe" +checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222" dependencies = [ "ahash", "arrow-array", @@ -376,6 +385,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -581,27 +596,12 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-polyfill" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" -dependencies = [ - "critical-section", -] - [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atomic_float" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" - [[package]] name = "attohttpc" version = "0.30.1" @@ -609,16 +609,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ "base64 0.22.1", - "http 1.4.0", + "http 1.4.2", "log", "url", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "av-scenechange" @@ -656,9 +656,9 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" dependencies = [ "arrayvec", ] @@ -694,16 +694,15 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "itoa", "matchit", "memchr", "mime", - "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -728,7 +727,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -751,19 +750,10 @@ dependencies = [ ] [[package]] -name = "backtrace" -version = "0.3.76" +name = "base16ct" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", -] +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base32" @@ -796,34 +786,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "serde", - "unty", -] - -[[package]] -name = "bindgen" -version = "0.71.1" +name = "beef" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" -dependencies = [ - "bitflags 2.11.1", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.2", - "shlex", - "syn 2.0.117", -] +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" [[package]] name = "bindgen" @@ -832,17 +798,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "annotate-snippets", - "bitflags 2.11.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", - "log", - "prettyplease", "proc-macro2", "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -864,6 +828,15 @@ dependencies = [ "bit-vec 0.8.0", ] +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec 0.9.1", +] + [[package]] name = "bit-vec" version = "0.6.3" @@ -876,6 +849,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bit_field" version = "0.10.3" @@ -890,9 +869,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -908,9 +887,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", @@ -922,9 +901,9 @@ dependencies = [ [[package]] name = "blas-src" -version = "0.14.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd439b48f5425c9f4597141b52476358e05e08471bc30f4d1c2a1f4084d7c9f" +checksum = "b95e83dc868db96e69795c0213143095f03de9dd3252f205d4ac716e4076a7e0" dependencies = [ "accelerate-src", "openblas-src", @@ -947,9 +926,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", ] @@ -1029,7 +1008,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bluez-generated", "dbus", "dbus-tokio", @@ -1099,9 +1078,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1110,14 +1089,23 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1125,6 +1113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -1134,9 +1123,9 @@ version = "0.11.8" source = "git+https://github.com/eugenehp/btleplug.git?branch=imrpoved_mac_version#c6055fe0e7b40c771a8b6d6c7548f00276a41d36" dependencies = [ "async-trait", - "bitflags 2.11.1", + "bitflags 2.13.0", "bluez-async", - "dashmap 6.1.0", + "dashmap 6.2.1", "dbus", "futures", "jni 0.19.0", @@ -1162,9 +1151,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c3264dbe2c8e29381e4e95aa2d2783ad0b9192b511240f3755b7e5e3cee87e" dependencies = [ "async-trait", - "bitflags 2.11.1", + "bitflags 2.13.0", "bluez-async", - "dashmap 6.1.0", + "dashmap 6.2.1", "dbus", "futures", "jni 0.19.0", @@ -1184,6658 +1173,6463 @@ dependencies = [ [[package]] name = "built" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "burn" -version = "0.20.1" +name = "by_address" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b78ff10ed98b73e1d477ea6e6e1ec1b9cf9f71a17afc3fea9f4dca482d43dcd4" -dependencies = [ - "burn-autodiff", - "burn-candle", - "burn-core", - "burn-cpu", - "burn-cuda", - "burn-ndarray", - "burn-nn", - "burn-optim", - "burn-rocm", - "burn-router", - "burn-store", - "burn-train", - "burn-wgpu", -] +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] -name = "burn-autodiff" -version = "0.20.1" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f04955e9b4acd5e6a6229f80217dae79742975a97dc2253003f226333ad307" -dependencies = [ - "burn-backend", - "burn-std", - "derive-new", - "hashbrown 0.16.1", - "log", - "num-traits", - "parking_lot", - "portable-atomic", - "spin 0.10.0", -] +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] -name = "burn-backend" -version = "0.20.1" +name = "bytemuck" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a724a5d8d5865a1f6b304f629eb19f51489760689501c583b3e1f4209f067357" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ - "burn-std", - "bytemuck", - "cubecl", - "derive-new", - "hashbrown 0.16.1", - "num-traits", - "rand 0.9.4", - "rand_distr", - "serde", - "thiserror 2.0.18", + "bytemuck_derive", ] [[package]] -name = "burn-candle" -version = "0.20.1" +name = "bytemuck_derive" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21c752d5008923eb9299783da5edae3242b94afdb956e88d2b37b025244b5071" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ - "burn-backend", - "burn-std", - "candle-core 0.9.2", - "derive-new", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "burn-core" -version = "0.20.1" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3634c3ba84397bcf2977ce746954d7e0a40e2d862e92362dd694c29e18df62" -dependencies = [ - "ahash", - "bincode", - "burn-dataset", - "burn-derive", - "burn-std", - "burn-tensor", - "data-encoding", - "derive-new", - "flate2", - "half", - "hashbrown 0.16.1", - "log", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rand 0.9.4", - "regex", - "rmp-serde", - "serde", - "serde_json", - "spin 0.10.0", - "uuid", -] +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "burn-cpu" -version = "0.20.1" +name = "byteorder-lite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60aa53c4536719f1c91c250d4b4348daca473c44cf0c45b81096785f5510c192" -dependencies = [ - "burn-backend", - "burn-cubecl", - "cubecl", -] +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] -name = "burn-cubecl" -version = "0.20.1" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d6d13aff03fec966da4300459688883f8a1d741dddbf19d1bfc2562656a9a9b" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ - "burn-backend", - "burn-cubecl-fusion", - "burn-fusion", - "burn-ir", - "burn-std", - "cubecl", - "cubek", - "derive-new", - "futures-lite", - "log", "serde", - "text_placeholder", ] [[package]] -name = "burn-cubecl-fusion" -version = "0.20.1" +name = "bzip2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17d25b2e9fb805931401f79782aabd92462d65e60bc207035a3e554de8d7cd9f" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" dependencies = [ - "burn-backend", - "burn-fusion", - "burn-ir", - "burn-std", - "cubecl", - "cubek", - "derive-new", - "serde", + "libbz2-rs-sys", ] [[package]] -name = "burn-cuda" -version = "0.20.1" +name = "cairo-rs" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d0c68cf653eb9c27dcbe046bb7b04cc18c6b33afda4c09317c102e6f4ae7cb6" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "burn-backend", - "burn-cubecl", - "cubecl", + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", ] [[package]] -name = "burn-dataset" -version = "0.20.1" +name = "cairo-sys-rs" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e87741e2ff9015845ed2b41b47f9e82795cf274bf2328a29619a2e6f662495c" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" dependencies = [ - "csv", - "derive-new", - "dirs", - "rand 0.9.4", - "rmp-serde", - "sanitize-filename", - "serde", - "serde_json", - "strum 0.27.2", - "tempfile", - "thiserror 2.0.18", + "glib-sys", + "libc", + "system-deps 6.2.2", ] [[package]] -name = "burn-derive" -version = "0.20.1" +name = "camino" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "102d7e2f705b0cda2f89dd0e55e9bbfc6184029929d53487beb606c3303b29a5" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ - "derive-new", - "proc-macro2", - "quote", - "syn 2.0.117", + "serde_core", ] [[package]] -name = "burn-fusion" -version = "0.20.1" +name = "candle-core" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea83d7f8574bcc07967291c5bb679ddc0a655c8db0642eca62755e2fffc8047" +checksum = "6bd9895436c1ba5dc1037a19935d084b838db066ff4e15ef7dded020b7c12a4a" dependencies = [ - "burn-backend", - "burn-ir", - "derive-new", - "hashbrown 0.16.1", - "log", - "serde", - "spin 0.10.0", - "tracing", + "byteorder", + "candle-metal-kernels", + "candle-ug", + "float8", + "gemm 0.19.0", + "half", + "libm", + "memmap2", + "num-traits", + "num_cpus", + "objc2-foundation 0.3.2", + "objc2-metal", + "rand 0.9.4", + "rand_distr 0.5.1", + "rayon", + "safetensors 0.7.0", + "thiserror 2.0.18", + "tokenizers", + "yoke 0.8.3", + "zip 7.2.0", ] [[package]] -name = "burn-ir" -version = "0.20.1" +name = "candle-metal-kernels" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd2b1b37a7289bd85438800deaaebde50507336429b80f96a71730794db5bc31" +checksum = "4b6b5a4cae6b4e1ab0efcee4dc05272d11b374a3d1ba121b3a961e36be54ab60" dependencies = [ - "burn-backend", - "hashbrown 0.16.1", - "serde", + "half", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-metal", + "once_cell", + "thiserror 2.0.18", + "tracing", ] [[package]] -name = "burn-mlx" -version = "0.1.2" -source = "git+https://github.com/eidola-ai/burn-mlx?branch=burn-0-20#beb4a1be231118ebfeeb104ee6af1a66ff01b4a4" +name = "candle-nn" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9317a09d6530b758990ed7f625ac69ff43653bc9ee28b0464644ad1169ada87" dependencies = [ - "burn", - "burn-tensor", - "derive-new", + "candle-core", + "candle-metal-kernels", "half", - "mlx-rs-burn", - "mlx-sys-burn", + "libc", "num-traits", + "objc2-metal", + "rayon", + "safetensors 0.7.0", + "serde", + "thiserror 2.0.18", ] [[package]] -name = "burn-ndarray" -version = "0.20.1" +name = "candle-ug" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96be578991cecef163e41a73bf985d8d7eb7fb8ef7bececf8d48523c481ecddf" +checksum = "ca0fc3167cbc99c8ec1be618cb620aa21dca95038f118c3579a79370e3dc5f77" dependencies = [ - "atomic_float", - "blas-src", - "burn-autodiff", - "burn-backend", - "burn-ir", - "burn-std", - "bytemuck", - "const-random", - "itertools 0.14.0", - "libm", - "macerator", - "matrixmultiply", - "ndarray 0.17.2", - "num-traits", - "openblas-src", - "paste", - "portable-atomic", - "portable-atomic-util", - "rand 0.9.4", - "rayon", - "seq-macro", + "ug", + "ug-metal", ] [[package]] -name = "burn-nn" -version = "0.20.1" +name = "cargo-platform" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b8c6c14b94e5b1dddd68f8e6d669f20bac8f99fcb2e4f1a480212d1b598133" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" dependencies = [ - "burn-core", - "num-traits", + "serde", ] [[package]] -name = "burn-optim" -version = "0.20.1" +name = "cargo_metadata" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a8c376d835d92ea363c05c6f48ac19bb687b683c7958c310a716ef8d5d77ba3" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ - "burn-core", - "derive-new", - "hashbrown 0.16.1", - "log", - "num-traits", + "camino", + "cargo-platform", + "semver", "serde", + "serde_json", + "thiserror 2.0.18", ] [[package]] -name = "burn-rocm" -version = "0.20.1" +name = "cargo_toml" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2abda6ee63bdcb730f1a335349a9ff83f03048130d405b6ecdccd2df3ff23" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ - "burn-backend", - "burn-cubecl", - "cubecl", + "serde", + "toml 0.9.12+spec-1.1.0", ] [[package]] -name = "burn-router" -version = "0.20.1" +name = "cast" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "823ccb88484736a2861d53dc7f67db375ef050b0446bb02dd7cb8783ac6b69a2" -dependencies = [ - "burn-backend", - "burn-ir", - "burn-std", - "hashbrown 0.16.1", - "log", - "spin 0.10.0", -] +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] -name = "burn-std" -version = "0.20.1" +name = "castaway" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25a9ed8e34a4a49d3754586f306075d6b55a5e08343ac75c06f47e7d9f825271" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ - "bytemuck", - "bytes", - "cubecl", - "cubecl-common", - "half", - "num-traits", - "serde", + "rustversion", ] [[package]] -name = "burn-store" -version = "0.20.1" +name = "cbc" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be80a7b084a19901dc1d0a2e9b77e226c5c575879fe66de891c67062db41a6d" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "burn-core", - "burn-nn", - "burn-tensor", - "byteorder", - "bytes", - "half", - "hashbrown 0.16.1", - "memmap2", - "regex", - "safetensors 0.7.0", - "textdistance", + "cipher", ] [[package]] -name = "burn-tensor" -version = "0.20.1" +name = "cblas-sys" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3720e52e00ed0155ced4f8681d0e8a362e699cee36494ec5b97ad44fcc5194c0" +checksum = "b6feecd82cce51b0204cf063f0041d69f24ce83f680d87514b004248e7b0fa65" dependencies = [ - "burn-backend", - "burn-std", - "colored", - "derive-new", - "num-traits", - "serde", + "libc", ] [[package]] -name = "burn-train" -version = "0.20.1" +name = "cc" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c3128c7571992c382a5ad057c72654c1048ea4dcf138d1f394313047e69803a" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ - "async-channel", - "burn-core", - "burn-ndarray", - "burn-optim", - "derive-new", - "log", - "rstest", - "serde", - "thiserror 2.0.18", - "tracing-appender", - "tracing-core", - "tracing-subscriber", + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] -name = "burn-wgpu" -version = "0.20.1" +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df78d62afc9b9fbb8ee4e49b72006485bb64f778a790e185a2d919479bcfc008" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "burn-backend", - "burn-cubecl", - "burn-fusion", - "cubecl", + "nom 7.1.3", ] [[package]] -name = "bytecount" -version = "0.6.9" +name = "cfb" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] [[package]] -name = "bytemuck" -version = "1.25.0" +name = "cfg-expr" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ - "bytemuck_derive", + "smallvec", + "target-lexicon 0.12.16", ] [[package]] -name = "bytemuck_derive" -version = "1.10.2" +name = "cfg-expr" +version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "smallvec", + "target-lexicon 0.13.5", ] [[package]] -name = "byteorder" -version = "1.5.0" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "byteorder-lite" -version = "0.1.0" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "bytes" -version = "1.11.1" +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "portable-atomic", - "serde", + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] -name = "bzip2" -version = "0.6.1" +name = "chrono" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "libbz2-rs-sys", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", ] [[package]] -name = "c2rust-bitfields" -version = "0.20.0" +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "ciborium" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46dc7d2bffa0d0b3d47eb2dc69973466858281446c2ac9f6d8a10e92ab1017df" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ - "c2rust-bitfields-derive", + "ciborium-io", + "ciborium-ll", + "serde", ] [[package]] -name = "c2rust-bitfields-derive" -version = "0.20.0" +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe1117afa5937ce280034e31fa1e84ed1824a252f75380327eed438535333f8" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "ciborium-io", + "half", ] [[package]] -name = "cairo-rs" -version = "0.18.5" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "bitflags 2.11.1", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", + "crypto-common 0.1.7", + "inout", ] [[package]] -name = "cairo-sys-rs" -version = "0.18.2" +name = "clang-sys" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ - "glib-sys", + "glob", "libc", - "system-deps 6.2.2", + "libloading 0.8.9", ] [[package]] -name = "camino" -version = "1.2.2" +name = "clap" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ - "serde_core", + "clap_builder", + "clap_derive", ] [[package]] -name = "candle-core" -version = "0.9.2" +name = "clap_builder" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c15b675b80d994b2eadb20a4bbe434eabeb454eac3ee5e2b4cf6f147ee9be091" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "byteorder", - "float8 0.6.1", - "gemm 0.19.0", - "half", - "libm", - "memmap2", - "num-traits", - "num_cpus", - "rand 0.9.4", - "rand_distr", - "rayon", - "safetensors 0.7.0", - "thiserror 2.0.18", - "yoke 0.8.2", - "zip 7.2.0", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] -name = "candle-core" -version = "0.10.2" +name = "clap_derive" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd9895436c1ba5dc1037a19935d084b838db066ff4e15ef7dded020b7c12a4a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "byteorder", - "candle-metal-kernels", - "candle-ug", - "float8 0.7.0", - "gemm 0.19.0", - "half", - "libm", - "memmap2", - "num-traits", - "num_cpus", - "objc2-foundation 0.3.2", - "objc2-metal", - "rand 0.9.4", - "rand_distr", - "rayon", - "safetensors 0.7.0", - "thiserror 2.0.18", - "tokenizers", - "yoke 0.8.2", - "zip 7.2.0", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "candle-metal-kernels" -version = "0.10.2" +name = "clap_lex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6b5a4cae6b4e1ab0efcee4dc05272d11b374a3d1ba121b3a961e36be54ab60" -dependencies = [ - "half", - "objc2 0.6.4", - "objc2-foundation 0.3.2", - "objc2-metal", - "once_cell", - "thiserror 2.0.18", - "tracing", -] +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] -name = "candle-nn" -version = "0.10.2" +name = "cmake" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9317a09d6530b758990ed7f625ac69ff43653bc9ee28b0464644ad1169ada87" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ - "candle-core 0.10.2", - "candle-metal-kernels", - "half", - "libc", - "num-traits", - "objc2-metal", - "rayon", - "safetensors 0.7.0", - "serde", - "thiserror 2.0.18", + "cc", ] [[package]] -name = "candle-ug" -version = "0.10.2" +name = "cmov" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0fc3167cbc99c8ec1be618cb620aa21dca95038f118c3579a79370e3dc5f77" -dependencies = [ - "ug", - "ug-metal", -] +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] -name = "cargo-platform" -version = "0.1.9" +name = "cobs" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "serde", + "thiserror 2.0.18", ] [[package]] -name = "cargo_metadata" -version = "0.19.2" +name = "codespan-reporting" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ - "camino", - "cargo-platform", - "semver", "serde", - "serde_json", - "thiserror 2.0.18", + "termcolor", + "unicode-width 0.2.2", ] [[package]] -name = "cargo_toml" -version = "0.22.3" +name = "coe-rs" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +checksum = "7e8f1e641542c07631228b1e0dc04b69ae3c1d58ef65d5691a439711d805c698" + +[[package]] +name = "cognionics" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13152f74c48f78489423bb0d6bbe204e4ddc50e208c8e39db014ba69726b5b21" dependencies = [ + "anyhow", + "env_logger", + "log", "serde", - "toml 0.9.12+spec-1.1.0", + "serde_json", + "serialport", + "tokio", ] [[package]] -name = "caseless" -version = "0.2.2" +name = "color_quant" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" -dependencies = [ - "unicode-normalization", -] +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] -name = "cast" -version = "0.3.0" +name = "colorchoice" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "castaway" -version = "0.2.4" +name = "combine" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ - "rustversion", + "bytes", + "memchr", ] [[package]] -name = "cbc" -version = "0.1.2" +name = "compact_str" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ - "cipher", + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", ] [[package]] -name = "cblas-sys" -version = "0.1.4" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6feecd82cce51b0204cf063f0041d69f24ce83f680d87514b004248e7b0fa65" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "libc", + "crossbeam-utils", ] [[package]] -name = "cc" -version = "1.2.60" +name = "console" +version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" dependencies = [ - "find-msvc-tools", - "jobserver", + "encode_unicode", "libc", - "shlex", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", ] [[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cexpr" -version = "0.6.0" +name = "console" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ - "nom 7.1.3", + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", ] [[package]] -name = "cfb" -version = "0.7.3" +name = "const-oid" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] -name = "cfg-expr" -version = "0.15.8" +name = "const-random" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" dependencies = [ - "smallvec", - "target-lexicon 0.12.16", + "const-random-macro", ] [[package]] -name = "cfg-expr" -version = "0.20.7" +name = "const-random-macro" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "smallvec", - "target-lexicon 0.13.3", + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", ] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "constant_time_eq" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" [[package]] -name = "cfg_aliases" -version = "0.2.1" +name = "constant_time_eq" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] -name = "chacha20" -version = "0.10.0" +name = "convert_case" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "unicode-segmentation", ] [[package]] -name = "chrono" -version = "0.4.44" +name = "convert_case" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.1", + "unicode-segmentation", ] [[package]] -name = "ciborium" -version = "0.2.2" +name = "cookie" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", + "percent-encoding", + "time", + "version_check", ] [[package]] -name = "ciborium-io" -version = "0.2.2" +name = "cookie-factory" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" [[package]] -name = "ciborium-ll" -version = "0.2.2" +name = "cookie_store" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" dependencies = [ - "ciborium-io", - "half", + "cookie", + "document-features", + "idna", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", ] [[package]] -name = "cipher" -version = "0.4.4" +name = "cordyceps" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" dependencies = [ - "crypto-common 0.1.7", - "inout", + "loom", + "tracing", ] [[package]] -name = "clang-sys" -version = "1.8.1" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "glob", + "core-foundation-sys", "libc", - "libloading 0.8.9", ] [[package]] -name = "clap" -version = "4.6.1" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "clap_builder", - "clap_derive", + "core-foundation-sys", + "libc", ] [[package]] -name = "clap_builder" -version = "4.6.0" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "clap_derive" -version = "4.6.1" +name = "core-graphics" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", ] [[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "cmake" -version = "0.1.58" +name = "core-graphics-types" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ - "cc", + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", ] [[package]] -name = "cobs" -version = "0.3.0" +name = "core-graphics-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "thiserror 2.0.18", + "bitflags 2.13.0", + "core-foundation 0.10.1", + "libc", ] [[package]] -name = "codespan-reporting" -version = "0.12.0" +name = "coreaudio-rs" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "serde", - "termcolor", - "unicode-width 0.2.2", + "bitflags 2.13.0", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", ] [[package]] -name = "cognionics" -version = "0.0.1" +name = "cpal" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13152f74c48f78489423bb0d6bbe204e4ddc50e208c8e39db014ba69726b5b21" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" dependencies = [ - "anyhow", - "env_logger", - "log", - "serde", - "serde_json", - "serialport", - "tokio", + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2 0.5.0", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", ] [[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "colorchoice" -version = "1.0.5" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] [[package]] -name = "colored" -version = "3.1.1" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "windows-sys 0.61.2", + "libc", ] [[package]] -name = "combine" -version = "4.6.7" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "bytes", - "memchr", + "cfg-if", ] [[package]] -name = "compact_str" -version = "0.9.0" +name = "criterion" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", "serde", - "static_assertions", + "serde_json", + "tinytemplate", + "walkdir", ] [[package]] -name = "comrak" -version = "0.39.1" +name = "criterion-plot" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fefab951771fc3beeed0773ce66a4f7b706273fc6c4c95b08dd1615744abcf5" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ - "caseless", - "entities", - "memchr", - "slug", - "typed-arena", - "unicode_categories", + "cast", + "itertools 0.13.0", ] [[package]] -name = "concurrent-queue" -version = "2.5.0" +name = "critical-section" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] -name = "console" -version = "0.15.11" +name = "crossbeam-deque" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width 0.2.2", - "windows-sys 0.59.0", + "crossbeam-epoch", + "crossbeam-utils", ] [[package]] -name = "console" -version = "0.16.3" +name = "crossbeam-epoch" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ - "encode_unicode", - "libc", - "unicode-width 0.2.2", - "windows-sys 0.61.2", + "crossbeam-utils", ] [[package]] -name = "const-oid" -version = "0.10.2" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "const-random" -version = "0.1.18" +name = "crossterm" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "const-random-macro", + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", ] [[package]] -name = "const-random-macro" -version = "0.1.16" +name = "crossterm_winapi" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", + "winapi", ] [[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - -[[package]] -name = "constant_time_eq" -version = "0.4.2" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "constcat" -version = "0.6.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d3e02915a2cea4d74caa8681e2d44b1c3254bdbf17d11d41d587ff858832c" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] [[package]] -name = "convert_case" -version = "0.4.0" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] [[package]] -name = "convert_case" -version = "0.8.0" +name = "csscolorparser" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ - "unicode-segmentation", + "lab", + "phf 0.11.3", ] [[package]] -name = "convert_case" -version = "0.10.0" +name = "cssparser" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" dependencies = [ - "unicode-segmentation", + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", ] [[package]] -name = "cookie" -version = "0.18.1" +name = "cssparser-macros" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ - "percent-encoding", - "time", - "version_check", + "quote", + "syn 2.0.117", ] [[package]] -name = "cookie-factory" -version = "0.3.3" +name = "csv" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] [[package]] -name = "cookie_store" -version = "0.22.1" +name = "csv-core" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" dependencies = [ - "cookie", - "document-features", - "idna", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "time", - "url", + "memchr", ] [[package]] -name = "cordyceps" -version = "0.3.4" +name = "ctor" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "loom", - "tracing", + "ctor-proc-macro", + "dtor", ] [[package]] -name = "core-foundation" -version = "0.9.4" +name = "ctor-proc-macro" +version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "core-foundation-sys", - "libc", + "cipher", ] [[package]] -name = "core-foundation" -version = "0.10.1" +name = "ctrlc" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ - "core-foundation-sys", - "libc", + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] [[package]] -name = "core-graphics" -version = "0.25.0" +name = "cudarc" +version = "0.19.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +checksum = "1cea5f10a99e025c1b44ae2354c2d8326b25ddbd0baf76bde8e55cfd4018a2cc" dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-graphics-types 0.2.0", - "foreign-types 0.5.0", - "libc", + "libloading 0.9.0", ] [[package]] -name = "core-graphics-types" -version = "0.1.3" +name = "curve25519-dalek" +version = "5.0.0-pre.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", ] [[package]] -name = "core-graphics-types" -version = "0.2.0" +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "libc", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "coreaudio-rs" -version = "0.14.1" +name = "custom_debug" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16dd574a72a021b90c7656c474ea31d11a2f0366a8eff574186e761e0b9e3586" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" dependencies = [ - "bitflags 2.11.1", - "libc", - "objc2-audio-toolbox", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", + "custom_debug_derive", ] [[package]] -name = "cpal" -version = "0.17.3" +name = "custom_debug_derive" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" dependencies = [ - "alsa", - "coreaudio-rs", - "dasp_sample", - "jni 0.21.1", - "js-sys", - "libc", - "mach2 0.5.0", - "ndk", - "ndk-context", - "num-derive", - "num-traits", - "objc2 0.6.4", - "objc2-audio-toolbox", - "objc2-avf-audio", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows 0.62.2", + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", ] [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "darling" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "libc", + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] -name = "cpufeatures" -version = "0.3.0" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "libc", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] -name = "crc" -version = "3.3.0" +name = "darling_core" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ - "crc-catalog", + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] -name = "crc-catalog" -version = "2.4.0" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "darling_macro" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "cfg-if", + "darling_core 0.20.11", + "quote", + "syn 2.0.117", ] [[package]] -name = "criterion" -version = "0.8.2" +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "alloca", - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools 0.13.0", - "num-traits", - "oorandom", - "page_size", - "plotters", - "rayon", - "regex", - "serde", - "serde_json", - "tinytemplate", - "walkdir", + "darling_core 0.23.0", + "quote", + "syn 2.0.117", ] [[package]] -name = "criterion-plot" -version = "0.8.2" +name = "dary_heap" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" dependencies = [ - "cast", - "itertools 0.13.0", + "serde", ] [[package]] -name = "critical-section" -version = "1.2.0" +name = "dashmap" +version = "5.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] [[package]] -name = "crossbeam-channel" -version = "0.5.15" +name = "dashmap" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ + "cfg-if", "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "dasp_sample" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "data-encoding", + "data-encoding-macro-internal", ] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "data-encoding-macro-internal" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ - "crossbeam-utils", + "data-encoding", + "syn 2.0.117", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "dbgf" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "e6ca96b45ca70b8045e0462f191bd209fcb3c3bfe8dbfb1257ada54c4dd59169" [[package]] -name = "crossterm" -version = "0.29.0" +name = "dbus" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" dependencies = [ - "bitflags 2.11.1", - "crossterm_winapi", - "derive_more 2.1.1", - "document-features", - "mio", - "parking_lot", - "rustix 1.1.4", - "signal-hook", - "signal-hook-mio", - "winapi", + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.61.2", ] [[package]] -name = "crossterm_winapi" -version = "0.9.1" +name = "dbus-crossroads" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" dependencies = [ - "winapi", + "dbus", ] [[package]] -name = "crunchy" -version = "0.2.4" +name = "dbus-secret-service" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "dbus-tokio" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" dependencies = [ - "generic-array", - "typenum", + "dbus", + "libc", + "tokio", ] [[package]] -name = "crypto-common" -version = "0.2.1" +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "der" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "hybrid-array", + "const-oid", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "csscolorparser" -version = "0.6.2" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "lab", - "phf 0.11.3", + "powerfmt", + "serde_core", ] [[package]] -name = "cssparser" -version = "0.29.6" +name = "derive_arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", "proc-macro2", "quote", - "smallvec", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] -name = "cssparser" -version = "0.36.0" +name = "derive_builder" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf 0.13.1", - "smallvec", + "derive_builder_macro", ] [[package]] -name = "cssparser-macros" -version = "0.6.1" +name = "derive_builder_core" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ + "darling 0.20.11", + "proc-macro2", "quote", "syn 2.0.117", ] [[package]] -name = "csv" -version = "1.4.0" +name = "derive_builder_macro" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", + "derive_builder_core", + "syn 2.0.117", ] [[package]] -name = "csv-core" -version = "0.1.13" +name = "derive_more" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "memchr", + "derive_more-impl", ] [[package]] -name = "ctor" -version = "0.2.9" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", + "proc-macro2", "quote", + "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] -name = "ctr" -version = "0.9.2" +name = "diatomic-waker" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "cipher", + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", ] [[package]] -name = "ctrlc" -version = "3.5.2" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "dispatch2", - "nix 0.31.2", - "windows-sys 0.61.2", + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.2", ] [[package]] -name = "cubecl" -version = "0.9.0" +name = "dirs" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053856efd5436224775b9423d43d86f53d5b1d3af9a6b9983d9a313a0922638f" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "cubecl-core", - "cubecl-cpu", - "cubecl-cuda", - "cubecl-hip", - "cubecl-ir", - "cubecl-runtime", - "cubecl-std", - "cubecl-wgpu", - "half", + "dirs-sys", ] [[package]] -name = "cubecl-common" -version = "0.9.0" +name = "dirs-next" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60bf8aaeb572c8cf2f2ffd07fa9bb1a2cf9336d1aa11ecd4d9a2f2e30c4be706" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "backtrace", - "bytemuck", - "bytes", "cfg-if", - "cfg_aliases", - "derive-new", - "derive_more 2.1.1", - "dirs", - "embassy-futures", - "embassy-time", - "float4", - "float8 0.4.2", - "futures-lite", - "half", - "hashbrown 0.15.5", - "log", - "num-traits", - "parking_lot", - "portable-atomic", - "portable-atomic-util", - "rand 0.9.4", - "sanitize-filename", - "serde", - "serde_bytes", - "serde_json", - "spin 0.10.0", - "tracing", - "wasm-bindgen-futures", - "web-time", + "dirs-sys-next", ] [[package]] -name = "cubecl-core" -version = "0.9.0" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98374a31d2b68b55709891169832ccf205408c201c5e023964482441f213d0b9" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ - "bitflags 2.11.1", - "bytemuck", - "cubecl-common", - "cubecl-ir", - "cubecl-macros", - "cubecl-runtime", - "derive-new", - "derive_more 2.1.1", - "enumset", - "float-ord", - "half", - "hashbrown 0.15.5", - "log", - "num-traits", - "paste", - "serde", - "serde_json", - "tracing", - "variadics_please", + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", ] [[package]] -name = "cubecl-cpp" -version = "0.9.0" +name = "dirs-sys-next" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb24d96c1ff84ab4def0a529e384311a15cb771310aaf2b640c312384c3bca23" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ - "bytemuck", - "cubecl-common", - "cubecl-core", - "cubecl-opt", - "cubecl-runtime", - "derive-new", - "half", - "itertools 0.14.0", - "log", + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] -name = "cubecl-cpu" -version = "0.9.0" +name = "dispatch2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "152588a6e16b6bda5e8216af7a6fad3d7de4697294b6ce0f6acbe3a9029ff674" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bytemuck", - "cubecl-common", - "cubecl-core", - "cubecl-opt", - "cubecl-runtime", - "cubecl-std", - "derive-new", - "half", - "log", - "paste", - "serde", - "sysinfo 0.36.1", - "tracel-llvm", - "tracel-llvm-bundler", + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", ] [[package]] -name = "cubecl-cuda" -version = "0.9.0" +name = "displaydoc" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f74a5750c45090d1fc5ddf6a19fea9a099aa1f6800b78f1167a2d60182d1d96" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ - "bytemuck", - "cubecl-common", - "cubecl-core", - "cubecl-cpp", - "cubecl-runtime", - "cubecl-zspace", - "cudarc", - "derive-new", - "half", - "log", - "serde", - "tracing", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "cubecl-hip" -version = "0.9.0" +name = "dlib" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbae9bc7ee6093d0d7a549c05873dff3478f9087b59eb09b223a97d642c849aa" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "bytemuck", - "cubecl-common", - "cubecl-core", - "cubecl-cpp", - "cubecl-hip-sys", - "cubecl-runtime", - "cubecl-zspace", - "derive-new", - "half", - "log", - "paste", - "serde", - "tracing", + "libloading 0.8.9", ] [[package]] -name = "cubecl-hip-sys" -version = "7.1.5280200" +name = "dlopen2" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcdd98f72d6f17836a0477bcd5ae5dd6b57a80fb62a3c0919f867a231f897f28" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ + "dlopen2_derive", "libc", - "regex", -] - -[[package]] -name = "cubecl-ir" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "361b608ff9f05024c7a7e381852689acd95b6af5af956d68734692b27d5f75ef" -dependencies = [ - "cubecl-common", - "cubecl-macros-internal", - "derive-new", - "derive_more 2.1.1", - "enumset", - "float-ord", - "fnv", - "half", - "hashbrown 0.15.5", - "num-traits", - "portable-atomic", - "serde", - "variadics_please", + "once_cell", + "winapi", ] [[package]] -name = "cubecl-macros" -version = "0.9.0" +name = "dlopen2_derive" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9a872d16207c6a27ed45942fd311a281394dd384b14a21f72131db1556a977" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ - "cubecl-common", - "darling 0.21.3", - "derive-new", - "ident_case", - "prettyplease", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] -name = "cubecl-macros-internal" -version = "0.9.0" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa3fa0626cdf28b9c49084c2bb51493bfde44378e22d90624aacaafb81da3588" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "litrs", ] [[package]] -name = "cubecl-opt" -version = "0.9.0" +name = "dom_query" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdcff25fdcbd82ea4277c30a81e162722859f57c6ae105c0a3c53f8bb91154f6" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ - "cubecl-common", - "cubecl-core", - "cubecl-ir", - "float-ord", - "log", - "num", - "petgraph", - "smallvec", - "stable-vec", - "type-map", + "bit-set 0.8.0", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", ] [[package]] -name = "cubecl-runtime" -version = "0.9.0" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b02e28997a8d75311afae4d2cea7b593eb125312f845874118a59d78c7a6b34c" -dependencies = [ - "async-channel", - "bytemuck", - "cfg-if", - "cfg_aliases", - "cubecl-common", - "cubecl-ir", - "derive-new", - "derive_more 2.1.1", - "dirs", - "enumset", - "foldhash 0.1.5", - "hashbrown 0.15.5", - "log", - "md5", - "serde", - "serde_json", - "spin 0.10.0", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", - "tracing", - "variadics_please", - "wasm-bindgen-futures", - "web-time", -] +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "cubecl-spirv" -version = "0.9.0" +name = "dpi" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d7d68a3e09d4782098f82b0b7347f3a9e54a9977b3b5a23145464a84cf14dc" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" dependencies = [ - "bitflags 2.11.1", - "cubecl-common", - "cubecl-core", - "cubecl-opt", - "cubecl-runtime", - "half", - "hashbrown 0.15.5", - "tracel-rspirv", + "serde", ] [[package]] -name = "cubecl-std" -version = "0.9.0" +name = "drm" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8ff5741c98b7a7a5944b4afb0b67dd7f5e0be41ce7f303b587f8b0d6430b29b" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" dependencies = [ - "cubecl-common", - "cubecl-core", - "cubecl-runtime", - "foldhash 0.1.5", - "half", - "num-traits", - "paste", - "serde", - "spin 0.10.0", - "variadics_please", + "bitflags 2.13.0", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", ] [[package]] -name = "cubecl-wgpu" -version = "0.9.0" +name = "drm-ffi" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29787364632fc7ec6a11cf3d95187f82f6fcce17d6bb4f0fb0dde580b837631d" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" dependencies = [ - "ash", - "async-channel", - "bytemuck", - "cfg-if", - "cfg_aliases", - "cubecl-common", - "cubecl-core", - "cubecl-cpp", - "cubecl-ir", - "cubecl-runtime", - "cubecl-spirv", - "derive-new", - "derive_more 2.1.1", - "half", - "hashbrown 0.15.5", - "log", - "sanitize-filename", - "tracel-ash", - "tracing", - "wgpu", + "drm-sys", + "rustix 1.1.4", ] [[package]] -name = "cubecl-zspace" -version = "0.9.0" +name = "drm-fourcc" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0f819071413b19a00b7105497e0f6d2cf3e7e9d65cbb8d4ecf1ddb29c61dc2" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" [[package]] -name = "cubek" -version = "0.1.1" +name = "drm-sys" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bb1cce47db02017925301bedec92ae84628493df3f9761ea7ac42a60c6146f8" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" dependencies = [ - "cubecl", - "cubek-attention", - "cubek-convolution", - "cubek-matmul", - "cubek-quant", - "cubek-random", - "cubek-reduce", + "libc", + "linux-raw-sys 0.9.4", ] [[package]] -name = "cubek-attention" -version = "0.1.1" +name = "dtoa" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7278bd122b2428af479f9af05285160613733c33c93b63ab3c6d25cd0460c18b" -dependencies = [ - "bytemuck", - "cubecl", - "cubecl-common", - "cubek-matmul", - "cubek-random", - "half", - "serde", -] +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] -name = "cubek-convolution" -version = "0.1.1" +name = "dtoa-short" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18eb04bca4ae104d62a56def04b04f3d079c42fe49aac62202c96876f90fa28b" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" dependencies = [ - "bytemuck", - "cubecl", - "cubecl-common", - "cubek-matmul", - "derive-new", - "enumset", - "half", - "serde", + "dtoa", ] [[package]] -name = "cubek-matmul" -version = "0.1.1" -source = "git+https://github.com/eugenehp/cubek.git?branch=cubek-matmul#73052f085f8b625aa3ead838fcc701f87829166a" +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" dependencies = [ - "bytemuck", - "cubecl", - "cubecl-common", - "half", - "serde", + "dtor-proc-macro", ] [[package]] -name = "cubek-quant" -version = "0.1.1" +name = "dtor-proc-macro" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96ec3ae04af324df2d615c2b394e270d58d6f08cb833d67633e2ba794de75916" -dependencies = [ - "cubecl", - "cubecl-common", - "half", - "serde", -] +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" [[package]] -name = "cubek-random" -version = "0.1.1" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65a34844d8b7f739185c1d24896137dcb73f458830444103b45f678585ad983e" -dependencies = [ - "cubecl", - "cubecl-common", - "half", - "num-traits", - "rand 0.9.4", - "serde", -] +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "cubek-reduce" -version = "0.1.1" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42397d9ed85bb3084dfb56ed26de75690b5b07caf42a32f4006b57eb23d5b6d6" -dependencies = [ - "cubecl", - "half", - "num-traits", - "serde", - "thiserror 2.0.18", -] +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "cudarc" -version = "0.18.2" +name = "dyn-stack" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aa12038120eb13347a6ae2ffab1d34efe78150125108627fd85044dd4d6ff1e" +checksum = "bf6fa63092e3ca9f602f6500fddd05502412b748c4c4682938565b44eb9e0066" dependencies = [ - "libloading 0.8.9", + "bytemuck", ] [[package]] -name = "curve25519-dalek" -version = "5.0.0-pre.1" +name = "dyn-stack" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.11.0-rc.10", - "fiat-crypto", - "rand_core 0.9.5", - "rustc_version", + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", "serde", + "sha2 0.11.0", + "signature", "subtle", "zeroize", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "eegdino" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +checksum = "60600a44953e784ab500e796719164b1b4bb2447905f143f702a884d08ef6910" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "anyhow", + "half", + "rayon", + "rlx", + "rustfft", + "safetensors 0.7.0", + "serde", + "serde_json", + "thiserror 2.0.18", ] [[package]] -name = "custom_debug" -version = "0.6.2" +name = "either" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" -dependencies = [ - "custom_debug_derive", -] +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] -name = "custom_debug_derive" -version = "0.6.2" +name = "email_address" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", + "serde", ] [[package]] -name = "darling" -version = "0.20.11" +name = "embed-resource" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg 0.55.0", ] [[package]] -name = "darling" -version = "0.21.3" +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "emotiv" +version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "c28936decb4e5cd37d5e93f3e7edd790970ef34e2f49b969c0193d375c24f14a" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "anyhow", + "chrono", + "env_logger", + "futures-util", + "log", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.26.2", + "tracing", + "uuid", ] [[package]] -name = "darling" -version = "0.23.0" +name = "encode_unicode" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "cfg-if", ] [[package]] -name = "darling_core" -version = "0.20.11" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "fnv", - "ident_case", + "heck 0.5.0", "proc-macro2", "quote", - "strsim", "syn 2.0.117", ] [[package]] -name = "darling_core" -version = "0.21.3" +name = "enum-assoc" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" dependencies = [ - "fnv", - "ident_case", "proc-macro2", "quote", - "strsim", "syn 2.0.117", ] [[package]] -name = "darling_core" -version = "0.23.0" +name = "enumflags2" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ - "ident_case", "proc-macro2", "quote", - "strsim", "syn 2.0.117", ] [[package]] -name = "darling_macro" -version = "0.20.11" +name = "env_filter" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", + "log", + "regex", ] [[package]] -name = "darling_macro" -version = "0.21.3" +name = "env_logger" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", ] [[package]] -name = "darling_macro" -version = "0.23.0" +name = "equator" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "c35da53b5a021d2484a7cc49b2ac7f2d840f8236a286f84202369bd338d761ea" dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", + "equator-macro 0.2.1", ] [[package]] -name = "dary_heap" -version = "0.3.9" +name = "equator" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" dependencies = [ - "serde", + "equator-macro 0.4.2", ] [[package]] -name = "dashmap" -version = "5.5.3" +name = "equator-macro" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc" dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "dashmap" -version = "6.1.0" +name = "equator-macro" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "dasp_sample" -version = "0.11.0" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "data-encoding" -version = "2.10.0" +name = "erased-serde" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] [[package]] -name = "dbus" -version = "0.9.11" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "futures-channel", - "futures-util", "libc", - "libdbus-sys", "windows-sys 0.61.2", ] [[package]] -name = "dbus-crossroads" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" -dependencies = [ - "dbus", -] - -[[package]] -name = "dbus-secret-service" -version = "4.1.0" +name = "esaxx-rs" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "aes", - "block-padding", - "cbc", - "dbus", - "fastrand", - "hkdf", - "num", - "once_cell", - "sha2 0.10.9", - "zeroize", -] +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" [[package]] -name = "dbus-tokio" -version = "0.7.6" +name = "espeak-ng" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +checksum = "083cee3337773bfa7f1c38fe022dab392438ced57f3a64c883011952d13b83a1" dependencies = [ - "dbus", - "libc", - "tokio", + "bitflags 2.13.0", + "espeak-ng-data-dict-ru", + "espeak-ng-data-dicts", + "espeak-ng-data-phonemes", + "thiserror 1.0.69", ] [[package]] -name = "deflate64" -version = "0.1.12" +name = "espeak-ng-data-dict-ru" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +checksum = "7b62f5cac7f103b5ef324f02b2fd883897404eac9766dd21d90864eeb633b7ff" [[package]] -name = "deltae" -version = "0.3.2" +name = "espeak-ng-data-dicts" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +checksum = "393d19356251c91ffc4f08f446af2a5331efb6b6a5e7d109b26172bdea594eea" [[package]] -name = "der" -version = "0.8.0" +name = "espeak-ng-data-phonemes" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] +checksum = "d65418fdbf64db690f177ae55b6cb0f01eec5ffd46f5e1cba6f841ae3f214ec0" [[package]] -name = "deranged" -version = "0.5.8" +name = "euclid" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ - "powerfmt", - "serde_core", + "num-traits", ] [[package]] -name = "derive-new" -version = "0.7.0" +name = "event-listener" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "concurrent-queue", + "parking", + "pin-project-lite", ] [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "event-listener", + "pin-project-lite", ] [[package]] -name = "derive_builder" -version = "0.20.2" +name = "exg" +version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +checksum = "a3250007dee9c140f3e974b92ac04937bed030287f27f793ba8340188e455579" dependencies = [ - "derive_builder_macro", + "anyhow", + "clap", + "ndarray 0.17.2", + "rayon", + "rustfft", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "derive_builder_core" -version = "0.20.2" +name = "exg-luna" +version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +checksum = "c7ec7573cd743e0827a2ead2b0692637cefb77d1a4f2513b2eb7688df1f9941a" dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", + "anyhow", + "exg", + "ndarray 0.17.2", + "serde_json", ] [[package]] -name = "derive_builder_macro" -version = "0.20.2" +name = "exr" +version = "1.74.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" dependencies = [ - "derive_builder_core", - "syn 2.0.117", + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", ] [[package]] -name = "derive_more" -version = "0.99.20" +name = "extended" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" [[package]] -name = "derive_more" -version = "2.1.1" +name = "faer" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "c2b19b8c3570ea226e507fe3dbae2aa9d7e0f16676abd35ea3adeeb9f90f7b5d" dependencies = [ - "derive_more-impl", + "bytemuck", + "coe-rs", + "dbgf", + "dyn-stack 0.11.0", + "equator 0.4.2", + "faer-entity", + "gemm 0.18.2", + "generativity", + "libm", + "matrixcompare", + "matrixcompare-core", + "nano-gemm", + "npyz", + "num-complex", + "num-traits", + "paste", + "rand 0.8.6", + "rand_distr 0.4.3", + "rayon", + "reborrow", + "serde", ] [[package]] -name = "derive_more-impl" -version = "2.1.1" +name = "faer-entity" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "072f96f1bc8b2b30dfc26c086baeadb63aae08019b1ed84721809b9fd2006685" dependencies = [ - "convert_case 0.10.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", - "unicode-xid", + "bytemuck", + "coe-rs", + "libm", + "num-complex", + "num-traits", + "pulp 0.18.22", + "reborrow", ] [[package]] -name = "deunicode" -version = "1.6.2" +name = "fallible-iterator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] -name = "diatomic-waker" -version = "0.2.3" +name = "fallible-streaming-iterator" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] -name = "digest" -version = "0.10.7" +name = "fancy-regex" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", + "bit-set 0.5.3", + "regex", ] [[package]] -name = "digest" -version = "0.11.0-rc.10" +name = "fancy-regex" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afa94b64bfc6549e6e4b5a3216f22593224174083da7a90db47e951c4fb31725" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" dependencies = [ - "block-buffer 0.11.0", - "const-oid", - "crypto-common 0.2.1", + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", ] [[package]] -name = "dirs" -version = "6.0.0" +name = "fancy-regex" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" dependencies = [ - "dirs-sys", + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", ] [[package]] -name = "dirs-next" -version = "2.0.0" +name = "fast-hnsw" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +checksum = "bf2403f0c8ef8efb4716e7d5683a31ef5be24cb2491be06dfea9802b26dbc9f9" dependencies = [ - "cfg-if", - "dirs-sys-next", + "memmap2", + "rand 0.8.6", ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "fast-srgb8" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "fastembed" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "1f54fc1188b7f7eac8f47be2ab7b3a79ffd842cc8ff2e38316dd59ba4858890e" dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", + "anyhow", + "candle-core", + "candle-nn", + "hf-hub 0.4.3", + "image", + "ndarray 0.17.2", + "ort", + "safetensors 0.7.0", + "serde", + "serde_json", + "tokenizers", ] [[package]] -name = "dispatch2" -version = "0.3.1" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "libc", - "objc2 0.6.4", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "displaydoc" -version = "0.2.5" +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "simd-adler32", ] [[package]] -name = "dlib" -version = "0.5.3" +name = "fiat-crypto" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "libloading 0.8.9", + "memoffset", + "rustc_version", ] [[package]] -name = "dlopen2" -version = "0.5.0" +name = "filedescriptor" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ "libc", - "once_cell", + "thiserror 1.0.69", "winapi", ] [[package]] -name = "dlopen2" -version = "0.8.2" +name = "filetime" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ - "dlopen2_derive", + "cfg-if", "libc", - "once_cell", - "winapi", ] [[package]] -name = "dlopen2_derive" -version = "0.4.3" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "document-features" -version = "0.2.12" +name = "finl_unicode" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" [[package]] -name = "dom_query" -version = "0.27.0" +name = "fixedbitset" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" -dependencies = [ - "bit-set 0.8.0", - "cssparser 0.36.0", - "foldhash 0.2.0", - "html5ever 0.38.0", - "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", -] +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] -name = "downcast-rs" -version = "1.2.1" +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] -name = "dpi" -version = "0.1.2" +name = "flatbuffers" +version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "serde", + "bitflags 2.13.0", + "rustc_version", ] [[package]] -name = "drm" -version = "0.14.1" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "bitflags 2.11.1", - "bytemuck", - "drm-ffi", - "drm-fourcc", - "libc", - "rustix 0.38.44", + "crc32fast", + "miniz_oxide", + "zlib-rs", ] [[package]] -name = "drm-ffi" -version = "0.9.1" +name = "float8" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +checksum = "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc" dependencies = [ - "drm-sys", - "rustix 1.1.4", + "half", + "num-traits", + "rand 0.9.4", + "rand_distr 0.5.1", ] [[package]] -name = "drm-fourcc" -version = "2.2.0" +name = "fluent-uri" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] [[package]] -name = "drm-sys" -version = "0.8.1" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" -dependencies = [ - "libc", - "linux-raw-sys 0.9.4", -] +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "dtoa" -version = "1.0.11" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "dtoa-short" -version = "0.3.5" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "dunce" -version = "1.0.5" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] [[package]] -name = "dyn-clone" -version = "1.0.20" +name = "foreign-types" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] [[package]] -name = "dyn-stack" -version = "0.13.2" +name = "foreign-types-macros" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ - "bytemuck", - "dyn-stack-macros", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "dyn-stack-macros" -version = "0.1.3" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] -name = "ed25519" -version = "3.0.0-rc.4" +name = "foreign-types-shared" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" -dependencies = [ - "pkcs8", - "serde", - "signature", -] +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] -name = "ed25519-dalek" -version = "3.0.0-pre.1" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core 0.9.5", - "serde", - "sha2 0.11.0-rc.2", - "signature", - "subtle", - "zeroize", + "percent-encoding", ] [[package]] -name = "either" -version = "1.15.0" +name = "fraction" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] [[package]] -name = "email_address" -version = "0.2.9" +name = "fsevent-sys" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" dependencies = [ - "serde", + "libc", ] [[package]] -name = "embassy-futures" -version = "0.1.2" +name = "fuchsia-cprng" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" [[package]] -name = "embassy-time" -version = "0.4.0" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f820157f198ada183ad62e0a66f554c610cdcd1a9f27d4b316358103ced7a1f8" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ - "cfg-if", - "critical-section", - "document-features", - "embassy-time-driver", - "embedded-hal 0.2.7", - "embedded-hal 1.0.0", - "embedded-hal-async", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", "futures-util", ] [[package]] -name = "embassy-time-driver" -version = "0.2.2" +name = "futures-buffered" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" dependencies = [ - "document-features", + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin", ] [[package]] -name = "embed-resource" -version = "3.0.8" +name = "futures-channel" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 0.9.12+spec-1.1.0", - "vswhom", - "winreg 0.55.0", -] - -[[package]] -name = "embed_plist" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" - -[[package]] -name = "embedded-hal" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" -dependencies = [ - "nb 0.1.3", - "void", + "futures-core", + "futures-sink", ] [[package]] -name = "embedded-hal" -version = "1.0.0" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "embedded-hal-async" -version = "1.0.0" +name = "futures-executor" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ - "embedded-hal 1.0.0", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" +name = "futures-io" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] -name = "emotiv" -version = "0.0.13" +name = "futures-lite" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28936decb4e5cd37d5e93f3e7edd790970ef34e2f49b969c0193d375c24f14a" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "anyhow", - "chrono", - "env_logger", - "futures-util", - "log", - "rustls", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite 0.26.2", - "tracing", - "uuid", + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", ] [[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "encoding_rs" -version = "0.8.35" +name = "futures-macro" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ - "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "endi" -version = "1.1.1" +name = "futures-sink" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] -name = "entities" -version = "1.0.1" +name = "futures-task" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] -name = "enum-as-inner" -version = "0.6.1" +name = "futures-util" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", ] [[package]] -name = "enum-assoc" -version = "1.3.0" +name = "fxhash" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "byteorder", ] [[package]] -name = "enumflags2" -version = "0.7.12" +name = "gbm" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" dependencies = [ - "enumflags2_derive", - "serde", + "bitflags 2.13.0", + "drm", + "drm-fourcc", + "gbm-sys", + "libc", + "wayland-backend", + "wayland-server", ] [[package]] -name = "enumflags2_derive" -version = "0.7.12" +name = "gbm-sys" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "libc", ] [[package]] -name = "enumset" -version = "1.1.10" +name = "gdk" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" dependencies = [ - "enumset_derive", - "serde", + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", ] [[package]] -name = "enumset_derive" -version = "0.14.0" +name = "gdk-pixbuf" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", ] [[package]] -name = "env_filter" -version = "1.0.1" +name = "gdk-pixbuf-sys" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" dependencies = [ - "log", - "regex", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", ] [[package]] -name = "env_logger" -version = "0.11.10" +name = "gdk-sys" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps 6.2.2", ] [[package]] -name = "equator" -version = "0.4.2" +name = "gdkwayland-sys" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" dependencies = [ - "equator-macro", + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps 6.2.2", ] [[package]] -name = "equator-macro" -version = "0.4.2" +name = "gdkx11" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", ] [[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.4.10" +name = "gdkx11-sys" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" dependencies = [ - "serde", - "serde_core", - "typeid", + "gdk-sys", + "glib-sys", + "libc", + "system-deps 6.2.2", + "x11", ] [[package]] -name = "errno" -version = "0.3.14" +name = "gemm" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" dependencies = [ - "libc", - "windows-sys 0.61.2", + "dyn-stack 0.13.2", + "gemm-c32 0.18.2", + "gemm-c64 0.18.2", + "gemm-common 0.18.2", + "gemm-f16 0.18.2", + "gemm-f32 0.18.2", + "gemm-f64 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", ] [[package]] -name = "esaxx-rs" -version = "0.1.10" +name = "gemm" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-c32 0.19.0", + "gemm-c64 0.19.0", + "gemm-common 0.19.0", + "gemm-f16 0.19.0", + "gemm-f32 0.19.0", + "gemm-f64 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] [[package]] -name = "espeak-ng" -version = "0.1.0" +name = "gemm-c32" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf53da2d1e3049cfff5ae289a05b456c753c15cbe0bff2b97251aef5a748da94" +checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" dependencies = [ - "bitflags 2.11.1", - "espeak-ng-data-dict-ru", - "espeak-ng-data-dicts", - "espeak-ng-data-phonemes", - "thiserror 1.0.69", + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", ] [[package]] -name = "espeak-ng-data-dict-ru" -version = "0.1.0" +name = "gemm-c32" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b62f5cac7f103b5ef324f02b2fd883897404eac9766dd21d90864eeb633b7ff" +checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] [[package]] -name = "espeak-ng-data-dicts" -version = "0.1.0" +name = "gemm-c64" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "393d19356251c91ffc4f08f446af2a5331efb6b6a5e7d109b26172bdea594eea" +checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] [[package]] -name = "espeak-ng-data-phonemes" -version = "0.1.0" +name = "gemm-c64" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d65418fdbf64db690f177ae55b6cb0f01eec5ffd46f5e1cba6f841ae3f214ec0" +checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] [[package]] -name = "euclid" -version = "0.22.14" +name = "gemm-common" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" dependencies = [ + "bytemuck", + "dyn-stack 0.13.2", + "half", + "libm", + "num-complex", "num-traits", + "once_cell", + "paste", + "pulp 0.21.5", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", ] [[package]] -name = "event-listener" -version = "5.4.1" +name = "gemm-common" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", + "bytemuck", + "dyn-stack 0.13.2", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp 0.22.2", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", ] [[package]] -name = "event-listener-strategy" -version = "0.5.4" +name = "gemm-f16" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" dependencies = [ - "event-listener", - "pin-project-lite", + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "gemm-f32 0.18.2", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", ] [[package]] -name = "exg" -version = "0.0.3" +name = "gemm-f16" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3250007dee9c140f3e974b92ac04937bed030287f27f793ba8340188e455579" +checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" dependencies = [ - "anyhow", - "clap", - "ndarray 0.17.2", + "dyn-stack 0.13.2", + "gemm-common 0.19.0", + "gemm-f32 0.19.0", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", "rayon", - "rustfft", - "safetensors 0.7.0", - "serde", - "serde_json", + "seq-macro", ] [[package]] -name = "exg-luna" -version = "0.0.3" +name = "gemm-f32" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7ec7573cd743e0827a2ead2b0692637cefb77d1a4f2513b2eb7688df1f9941a" +checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" dependencies = [ - "anyhow", - "exg", - "ndarray 0.17.2", - "serde_json", + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", ] [[package]] -name = "exr" -version = "1.74.0" +name = "gemm-f32" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", + "dyn-stack 0.13.2", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", ] [[package]] -name = "extended" -version = "0.1.0" +name = "gemm-f64" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] [[package]] -name = "fallible-iterator" -version = "0.3.0" +name = "gemm-f64" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] [[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" +name = "generativity" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +checksum = "d2c81fb5260e37854d09d5c87183309fd8c555b75289427884b25660bc87a85e" [[package]] -name = "fancy-regex" -version = "0.11.0" +name = "generator" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ - "bit-set 0.5.3", - "regex", + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] -name = "fancy-regex" -version = "0.14.0" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "bit-set 0.8.0", - "regex-automata", - "regex-syntax", + "typenum", + "version_check", ] [[package]] -name = "fancy-regex" -version = "0.17.0" +name = "gethostname" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "bit-set 0.8.0", - "regex-automata", - "regex-syntax", + "rustix 1.1.4", + "windows-link 0.2.1", ] [[package]] -name = "fast-hnsw" -version = "1.0.1" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf2403f0c8ef8efb4716e7d5683a31ef5be24cb2491be06dfea9802b26dbc9f9" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "memmap2", - "rand 0.8.6", + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", ] [[package]] -name = "fast-umap" -version = "1.6.0" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a8e69887caf321df946c7d2d7c8ec6fe64bdaf50503ea61f57ab41a0d96bd8" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "burn", - "burn-cubecl", - "burn-mlx", - "crossbeam-channel", - "ctrlc", - "cubecl", - "indicatif 0.18.4", - "ndarray 0.16.1", - "num", - "num-traits", - "prettytable", - "rand 0.9.4", - "rayon", - "serde", + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] -name = "fastbloom" -version = "0.14.1" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "getrandom 0.3.4", - "libm", - "rand 0.9.4", - "siphasher 1.0.2", + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", ] [[package]] -name = "fastembed" -version = "5.13.2" +name = "ghash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f54fc1188b7f7eac8f47be2ab7b3a79ffd842cc8ff2e38316dd59ba4858890e" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ - "anyhow", - "candle-core 0.10.2", - "candle-nn", - "hf-hub 0.4.3", - "image", - "ndarray 0.17.2", - "ort", - "safetensors 0.7.0", - "serde", - "serde_json", - "tokenizers", + "opaque-debug", + "polyval", ] [[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fax" -version = "0.2.6" +name = "gif" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ - "fax_derive", + "color_quant", + "weezl", ] [[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +name = "gio" +version = "0.18.5" +source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", ] [[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +name = "gio-sys" +version = "0.18.5" +source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" dependencies = [ - "simd-adler32", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", + "winapi", ] [[package]] -name = "fiat-crypto" -version = "0.3.0" +name = "gl" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] [[package]] -name = "field-offset" -version = "0.3.6" +name = "gl_generator" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" dependencies = [ - "memoffset", - "rustc_version", + "khronos_api", + "log", + "xml-rs", ] [[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +name = "glib" +version = "0.18.5" +source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", "libc", + "memchr", + "once_cell", + "smallvec", "thiserror 1.0.69", - "winapi", ] [[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +name = "glib-macros" +version = "0.18.5" +source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.5" +source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" dependencies = [ - "cfg-if", "libc", - "libredox", + "system-deps 6.2.2", ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] -name = "finl_unicode" -version = "1.4.0" +name = "global-hotkey" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] [[package]] -name = "fixedbitset" -version = "0.4.2" +name = "globset" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] [[package]] -name = "flatbuffers" -version = "24.12.23" +name = "gloo-timers" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" dependencies = [ - "bitflags 1.3.2", - "rustc_version", + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "flatbuffers" -version = "25.12.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +name = "gobject-sys" +version = "0.18.5" +source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" dependencies = [ - "bitflags 2.11.1", - "rustc_version", + "glib-sys", + "libc", + "system-deps 6.2.2", ] [[package]] -name = "flate2" -version = "1.1.9" +name = "gpu-allocator" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.18", + "windows 0.62.2", ] [[package]] -name = "float-ord" +name = "gpu-descriptor" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" - -[[package]] -name = "float4" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5939bac0ef2ad7c83a53e4fb889c1d81f007b07061d648cd271071984d86f257" - -[[package]] -name = "float8" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4203231de188ebbdfb85c11f3c20ca2b063945710de04e7b59268731e728b462" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "half", + "bitflags 2.13.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", ] [[package]] -name = "float8" -version = "0.6.1" +name = "gpu-descriptor-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "half", - "num-traits", - "rand 0.9.4", - "rand_distr", + "bitflags 2.13.0", ] [[package]] -name = "float8" -version = "0.7.0" +name = "grayscale" +version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d1f04709a8ac06e8e8042875a3c466cc4832d3c1a18dbcb9dba3c6e83046bc" -dependencies = [ - "half", - "num-traits", - "rand 0.9.4", - "rand_distr", -] +checksum = "042686e9bd899e5fc60e43899a8c1ac312a1114c693c836e6b1e895f42083969" [[package]] -name = "fluent-uri" -version = "0.4.1" +name = "gtec" +version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +checksum = "3d7c162ee5089d7b975f98b80df0ae656050d3dd2e94314cb4c266c05f1f16b2" dependencies = [ - "borrow-or-share", - "ref-cast", - "serde", + "env_logger", + "libc", + "libloading 0.8.9", + "log", + "thiserror 2.0.18", ] [[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" +name = "gtk" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] [[package]] -name = "foldhash" -version = "0.2.0" +name = "gtk-sys" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps 6.2.2", +] [[package]] -name = "foreign-types" -version = "0.3.2" +name = "gtk3-macros" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" dependencies = [ - "foreign-types-shared 0.1.1", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "foreign-types" -version = "0.5.0" +name = "h2" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ - "foreign-types-macros", - "foreign-types-shared 0.3.1", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "foreign-types-macros" -version = "0.2.3" +name = "h2" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "bytemuck", + "cfg-if", + "crunchy", + "num-traits", + "rand 0.9.4", + "rand_distr 0.5.1", + "zerocopy", +] [[package]] -name = "foreign-types-shared" -version = "0.3.1" +name = "hashbrown" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "hashbrown" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] -name = "fraction" -version = "0.15.4" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "lazy_static", - "num", + "foldhash 0.1.5", ] [[package]] -name = "fsevent-sys" -version = "4.1.0" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "libc", + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] -name = "futf" -version = "0.1.5" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "mac", - "new_debug_unreachable", + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] -name = "futures" -version = "0.3.32" +name = "hashlink" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "hashbrown 0.16.1", ] [[package]] -name = "futures-buffered" -version = "0.2.13" +name = "hdrhistogram" +version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" dependencies = [ - "cordyceps", - "diatomic-waker", - "futures-core", - "pin-project-lite", - "spin 0.10.0", + "byteorder", + "num-traits", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "heck" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] -name = "futures-core" -version = "0.3.32" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "futures-executor" -version = "0.3.32" +name = "hermes-ble" +version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "d78d129e519fc86e171db6e4f899aae81f3b059a6a063e7328de901d59341151" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "anyhow", + "btleplug 0.12.0", + "env_logger", + "futures", + "log", + "tokio", + "uuid", ] [[package]] -name = "futures-io" -version = "0.3.32" +name = "hermit-abi" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "futures-sink" -version = "0.3.32" +name = "hexf-parse" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] -name = "futures-task" -version = "0.3.32" +name = "hf-hub" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs", + "http 1.4.2", + "indicatif 0.17.11", + "libc", + "log", + "native-tls", + "rand 0.9.4", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq 2.12.1", + "windows-sys 0.60.2", +] [[package]] -name = "futures-timer" -version = "3.0.3" +name = "hf-hub" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" +dependencies = [ + "dirs", + "futures", + "http 1.4.2", + "indicatif 0.18.4", + "libc", + "log", + "native-tls", + "num_cpus", + "rand 0.9.4", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "ureq 3.3.0", + "windows-sys 0.61.2", +] [[package]] -name = "futures-util" -version = "0.3.32" +name = "hickory-net" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", "futures-channel", - "futures-core", "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "futures-util", + "h2 0.4.14", + "hickory-proto", + "http 1.4.2", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", ] [[package]] -name = "fxhash" -version = "0.2.1" +name = "hickory-proto" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" dependencies = [ - "byteorder", + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", ] [[package]] -name = "gbm" -version = "0.18.0" +name = "hickory-resolver" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" dependencies = [ - "bitflags 2.11.1", - "drm", - "drm-fourcc", - "gbm-sys", - "libc", - "wayland-backend", - "wayland-server", + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration 0.7.0", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", ] [[package]] -name = "gbm-sys" -version = "0.4.0" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "libc", + "hmac", ] [[package]] -name = "gdk" -version = "0.18.2" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", + "digest 0.10.7", ] [[package]] -name = "gdk-pixbuf" -version = "0.18.5" +name = "hmac-sha256" +version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" [[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" +name = "hostname" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", "libc", - "system-deps 6.2.2", + "match_cfg", + "winapi", ] [[package]] -name = "gdk-sys" -version = "0.18.2" +name = "hotpath" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +checksum = "bff002d5c53fa1c6891f32156b9451d16654bc8a761894d7660b25c0a332d517" dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "arc-swap", + "cfg-if", + "crossbeam-channel", + "flate2", + "futures-util", + "hdrhistogram", + "hotpath-macros", + "hotpath-meta", "libc", - "pango-sys", - "pkg-config", - "system-deps 6.2.2", + "object 0.36.7", + "pin-project-lite", + "prettytable-rs", + "quanta", + "regex", + "rustc-demangle", + "serde", + "serde_json", + "tiny_http", + "tokio", ] [[package]] -name = "gdkwayland-sys" -version = "0.18.2" +name = "hotpath-macros" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +checksum = "9e2f4ac4534511584b7082657e133dcf3d8727b2f456a6b2a2c3eb02b82c1277" dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "pkg-config", - "system-deps 6.2.2", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "gdkx11" -version = "0.18.2" +name = "hotpath-macros-meta" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +checksum = "a6a87070853e9402ec79184f8d8d930d7eb86cd274aecdcf973f73b6f40271b0" + +[[package]] +name = "hotpath-meta" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31cfef2b9d280ad754c23b40b50cc74676489597f3cebfe0c180389e08a53ed" dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", + "hotpath-macros-meta", ] [[package]] -name = "gdkx11-sys" -version = "0.18.2" +name = "hound" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps 6.2.2", - "x11", + "log", + "markup5ever", ] [[package]] -name = "gemm" -version = "0.18.2" +name = "http" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ - "dyn-stack", - "gemm-c32 0.18.2", - "gemm-c64 0.18.2", - "gemm-common 0.18.2", - "gemm-f16 0.18.2", - "gemm-f32 0.18.2", - "gemm-f64 0.18.2", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", + "bytes", + "fnv", + "itoa", ] [[package]] -name = "gemm" -version = "0.19.0" +name = "http" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ - "dyn-stack", - "gemm-c32 0.19.0", - "gemm-c64 0.19.0", - "gemm-common 0.19.0", - "gemm-f16 0.19.0", - "gemm-f32 0.19.0", - "gemm-f64 0.19.0", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", + "bytes", + "itoa", ] [[package]] -name = "gemm-c32" -version = "0.18.2" +name = "http-body" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ - "dyn-stack", - "gemm-common 0.18.2", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", + "bytes", + "http 0.2.12", + "pin-project-lite", ] [[package]] -name = "gemm-c32" -version = "0.19.0" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "dyn-stack", - "gemm-common 0.19.0", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", + "bytes", + "http 1.4.2", ] [[package]] -name = "gemm-c64" -version = "0.18.2" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "dyn-stack", - "gemm-common 0.18.2", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.0.1", + "pin-project-lite", ] [[package]] -name = "gemm-c64" -version = "0.19.0" +name = "http-range-header" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" -dependencies = [ - "dyn-stack", - "gemm-common 0.19.0", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", -] +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" [[package]] -name = "gemm-common" -version = "0.18.2" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" -dependencies = [ - "bytemuck", - "dyn-stack", - "half", - "libm", - "num-complex", - "num-traits", - "once_cell", - "paste", - "pulp 0.21.5", - "raw-cpuid", - "rayon", - "seq-macro", - "sysctl", -] +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "gemm-common" -version = "0.19.0" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ - "bytemuck", - "dyn-stack", - "half", - "libm", - "num-complex", - "num-traits", - "once_cell", - "paste", - "pulp 0.22.2", - "raw-cpuid", - "rayon", - "seq-macro", - "sysctl", + "typenum", ] [[package]] -name = "gemm-f16" -version = "0.18.2" +name = "hyper" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ - "dyn-stack", - "gemm-common 0.18.2", - "gemm-f32 0.18.2", - "half", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "rayon", - "seq-macro", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", ] [[package]] -name = "gemm-f16" -version = "0.19.0" +name = "hyper" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ - "dyn-stack", - "gemm-common 0.19.0", - "gemm-f32 0.19.0", - "half", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "rayon", - "seq-macro", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.2", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "gemm-f32" -version = "0.18.2" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "dyn-stack", - "gemm-common 0.18.2", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", + "http 1.4.2", + "hyper 1.10.1", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] -name = "gemm-f32" -version = "0.19.0" +name = "hyper-tls" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ - "dyn-stack", - "gemm-common 0.19.0", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", ] [[package]] -name = "gemm-f64" -version = "0.18.2" +name = "hyper-tls" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ - "dyn-stack", - "gemm-common 0.18.2", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", + "bytes", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] -name = "gemm-f64" -version = "0.19.0" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "dyn-stack", - "gemm-common 0.19.0", - "num-complex", - "num-traits", - "paste", - "raw-cpuid", - "seq-macro", + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "hyper 1.10.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.4", + "system-configuration 0.7.0", + "tokio", + "tower-service", + "tracing", + "windows-registry", ] [[package]] -name = "generator" -version = "0.8.8" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "cc", - "cfg-if", - "libc", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", "log", - "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "wasm-bindgen", + "windows-core 0.62.2", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "typenum", - "version_check", + "cc", ] [[package]] -name = "gethostname" -version = "1.1.0" +name = "ico" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ - "rustix 1.1.4", - "windows-link 0.2.1", + "byteorder", + "png 0.17.16", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke 0.8.3", + "zerofrom", + "zerovec", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", - "wasm-bindgen", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "ghash" -version = "0.5.1" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "opaque-debug", - "polyval", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "gif" -version = "0.14.2" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ - "color_quant", - "weezl", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke 0.8.3", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "gimli" -version = "0.32.3" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] -name = "gio" -version = "0.18.5" -source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", + "idna_adapter", "smallvec", - "thiserror 1.0.69", + "utf8_iter", ] [[package]] -name = "gio-sys" -version = "0.18.5" -source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps 6.2.2", - "winapi", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "gl" -version = "0.14.0" +name = "idun" +version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +checksum = "ef4870c15acd4691d274c8817b5987f968b8bf9b3be2ebeed3d1cfe14f1547c0" dependencies = [ - "gl_generator", + "anyhow", + "base64 0.22.1", + "btleplug 0.11.8", + "env_logger", + "futures", + "log", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.24.0", + "uuid", ] [[package]] -name = "gl_generator" -version = "0.14.0" +name = "igd-next" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" dependencies = [ - "khronos_api", + "attohttpc", + "bytes", + "futures", + "http 1.4.2", + "http-body-util", + "hyper 1.10.1", + "hyper-util", "log", - "xml-rs", + "rand 0.10.1", + "tokio", + "url", + "xmltree", ] [[package]] -name = "glib" -version = "0.18.5" -source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" dependencies = [ - "bitflags 2.11.1", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", + "crossbeam-deque", + "globset", + "log", "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", ] [[package]] -name = "glib-macros" -version = "0.18.5" -source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.117", + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", ] [[package]] -name = "glib-sys" -version = "0.18.5" -source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ - "libc", - "system-deps 6.2.2", + "byteorder-lite", + "quick-error 2.0.1", ] [[package]] -name = "glob" -version = "0.3.3" +name = "imgref" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" [[package]] -name = "global-hotkey" -version = "0.7.0" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2 0.6.4", - "objc2-app-kit", - "once_cell", + "autocfg", + "hashbrown 0.12.3", "serde", - "thiserror 2.0.18", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", -] - -[[package]] -name = "globset" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", ] [[package]] -name = "gloo-timers" -version = "0.3.0" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] -name = "glow" -version = "0.16.0" +name = "indicatif" +version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", + "console 0.15.11", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.2", + "web-time", ] [[package]] -name = "glutin_wgl_sys" -version = "0.6.1" +name = "indicatif" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gobject-sys" -version = "0.18.5" -source = "git+https://github.com/eugenehp/gtk-rs-core.git?branch=0.18-patched#801780c451f4bfc0e9f50ac098ff73b6b870d16d" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ - "glib-sys", - "libc", - "system-deps 6.2.2", + "console 0.16.3", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", ] [[package]] -name = "gpu-alloc" -version = "0.6.0" +name = "indoc" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ - "bitflags 2.11.1", - "gpu-alloc-types", + "rustversion", ] [[package]] -name = "gpu-alloc-types" -version = "0.3.0" +name = "infer" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" dependencies = [ - "bitflags 2.11.1", + "cfb", ] [[package]] -name = "gpu-allocator" -version = "0.27.0" +name = "inotify" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "windows 0.58.0", + "bitflags 2.13.0", + "inotify-sys", + "libc", ] [[package]] -name = "gpu-descriptor" -version = "0.3.2" +name = "inotify-sys" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" dependencies = [ - "bitflags 2.11.1", - "gpu-descriptor-types", - "hashbrown 0.15.5", + "libc", ] [[package]] -name = "gpu-descriptor-types" -version = "0.2.0" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "bitflags 2.11.1", + "block-padding", + "generic-array", ] [[package]] -name = "gpu-fft" -version = "1.2.0" +name = "instability" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20b072bea65dca4cf4b13b946665687baea0fe7cd97838f67223966737fba366" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "cc", - "cubecl", + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "grayscale" -version = "0.0.1" +name = "integer-encoding" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "042686e9bd899e5fc60e43899a8c1ac312a1114c693c836e6b1e895f42083969" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] -name = "gtec" -version = "0.0.2" +name = "interpolate_name" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7c162ee5089d7b975f98b80df0ae656050d3dd2e94314cb4c266c05f1f16b2" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ - "env_logger", - "libc", - "libloading 0.8.9", - "log", - "thiserror 2.0.18", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "gtk" -version = "0.18.2" +name = "inventory" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", + "rustversion", ] [[package]] -name = "gtk-sys" -version = "0.18.2" +name = "io-kit-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps 6.2.2", + "core-foundation-sys", + "mach2 0.4.3", ] [[package]] -name = "gtk3-macros" -version = "0.18.2" +name = "ipconfig" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.117", + "socket2 0.6.4", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", ] [[package]] -name = "h2" -version = "0.3.27" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", + "serde", ] [[package]] -name = "h2" -version = "0.4.13" +name = "iroh" +version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "bef865dc2d11a19fe670ff217b68ffc3b511bddf473dc3a3e120090b9f691803" dependencies = [ - "atomic-waker", + "backon", + "blake3", "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.0", - "indexmap 2.14.0", - "slab", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.2", + "hickory-resolver", + "http 1.4.2", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.1", + "reqwest 0.13.4", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "smallvec", + "strum 0.28.0", + "time", "tokio", + "tokio-stream", "tokio-util", "tracing", + "url", + "wasm-bindgen-futures", + "webpki-roots 1.0.7", ] [[package]] -name = "half" -version = "2.7.1" +name = "iroh-base" +version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +checksum = "af93d67701c00c504982154569192ad384738c0450ba1196930314b955100552" dependencies = [ - "bytemuck", - "cfg-if", - "crunchy", - "num-traits", - "rand 0.9.4", - "rand_distr", + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "digest 0.11.3", + "ed25519-dalek", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", "serde", - "zerocopy", + "sha2 0.11.0", + "url", + "zeroize", + "zeroize_derive", ] [[package]] -name = "hash32" -version = "0.2.1" +name = "iroh-dns" +version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +checksum = "de4112c91eb64094d77df9d3112606dcf7ff216421afccd2dc762fda5a7b2879" dependencies = [ - "byteorder", + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "rand 0.10.1", + "reqwest 0.13.4", + "rustls", + "simple-dns", + "strum 0.28.0", + "tokio", + "tracing", + "url", ] [[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +name = "iroh-example-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "base32", + "iroh", + "iroh-base", + "serde", + "serde_json", + "skill-iroh", + "tempfile", + "tokio", + "totp-rs", + "url", +] [[package]] -name = "hashbrown" -version = "0.13.2" +name = "iroh-metrics" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +checksum = "d102597d0ee523f17fdb672c532395e634dbe945429284c811430d63bacc0d8a" dependencies = [ - "ahash", + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", ] [[package]] -name = "hashbrown" -version = "0.14.5" +name = "iroh-metrics-derive" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "91c8e0c97f1dc787107f388433c349397c565572fe6406d600ff7bb7b7fe3b30" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "iroh-relay" +version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "a70030b9e71c1183bd4f88fbdbebfa1af2a5be549dd6f20a1e8ac3cd0202ee9d" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "hickory-resolver", + "http 1.4.2", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "reqwest 0.13.4", + "rustls", + "rustls-pki-types", "serde", + "serde_bytes", + "strum 0.28.0", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots 1.0.7", + "ws_stream_wasm", ] [[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +name = "iroh_test_client" +version = "0.1.0" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", + "anyhow", + "base32", + "clap", + "env_logger", + "iroh", + "iroh-base", + "rand 0.10.1", "serde", - "serde_core", + "serde_json", + "skill-iroh", + "tempfile", + "tokio", + "totp-rs", + "url", ] [[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - -[[package]] -name = "hashlink" -version = "0.11.0" +name = "is-docker" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" dependencies = [ - "hashbrown 0.16.1", + "once_cell", ] [[package]] -name = "heapless" -version = "0.7.17" +name = "is-terminal" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "atomic-polyfill", - "hash32", - "rustc_version", - "serde", - "spin 0.9.8", - "stable_deref_trait", + "hermit-abi", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "heck" -version = "0.4.1" +name = "is-wsl" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] [[package]] -name = "heck" -version = "0.5.0" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "hermes-ble" -version = "0.0.1" +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78d129e519fc86e171db6e4f899aae81f3b059a6a063e7328de901d59341151" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ - "anyhow", - "btleplug 0.12.0", - "env_logger", - "futures", - "log", - "tokio", - "uuid", + "either", ] [[package]] -name = "hermit-abi" -version = "0.5.2" +name = "itertools" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] [[package]] -name = "hex" -version = "0.4.3" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "hexf-parse" -version = "0.2.1" +name = "javascriptcore-rs" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] [[package]] -name = "hf-hub" -version = "0.4.3" +name = "javascriptcore-rs-sys" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" dependencies = [ - "dirs", - "http 1.4.0", - "indicatif 0.17.11", + "glib-sys", + "gobject-sys", "libc", - "log", - "native-tls", - "rand 0.9.4", - "reqwest 0.12.28", - "serde", - "serde_json", - "thiserror 2.0.18", - "ureq 2.12.1", - "windows-sys 0.60.2", + "system-deps 6.2.2", ] [[package]] -name = "hf-hub" -version = "0.5.0" +name = "jiff" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ - "dirs", - "futures", - "http 1.4.0", - "indicatif 0.18.4", - "libc", + "jiff-static", "log", - "native-tls", - "num_cpus", - "rand 0.9.4", - "reqwest 0.12.28", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "ureq 3.3.0", - "windows-sys 0.61.2", + "portable-atomic", + "portable-atomic-util", + "serde_core", ] [[package]] -name = "hickory-proto" -version = "0.25.2" +name = "jiff-static" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ - "async-trait", - "bytes", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "h2 0.4.13", - "http 1.4.0", - "idna", - "ipnet", - "once_cell", - "rand 0.9.4", - "ring", - "rustls", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tokio-rustls", - "tracing", - "url", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "hickory-resolver" -version = "0.25.2" +name = "jni" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand 0.9.4", - "resolv-conf", - "rustls", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tokio-rustls", - "tracing", + "cesu8", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", ] [[package]] -name = "hkdf" -version = "0.12.4" +name = "jni" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ - "hmac", + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", ] [[package]] -name = "hmac" -version = "0.12.1" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "digest 0.10.7", + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", ] [[package]] -name = "hmac-sha256" -version = "1.1.14" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] [[package]] -name = "hostname" +name = "jni-sys" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" dependencies = [ - "libc", - "match_cfg", - "winapi", + "jni-sys 0.4.1", ] [[package]] -name = "hound" -version = "3.5.1" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] [[package]] -name = "html5ever" -version = "0.29.1" +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", + "quote", + "syn 2.0.117", ] [[package]] -name = "html5ever" -version = "0.38.0" +name = "jni-utils" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "259e9f2c3ead61de911f147000660511f07ab00adeed1d84f5ac4d0386e7a6c4" dependencies = [ + "dashmap 5.5.3", + "futures", + "jni 0.19.0", "log", - "markup5ever 0.38.0", + "once_cell", + "static_assertions", + "uuid", ] [[package]] -name = "http" -version = "0.2.12" +name = "jobserver" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "bytes", - "fnv", - "itoa", + "getrandom 0.3.4", + "libc", ] [[package]] -name = "http" -version = "1.4.0" +name = "js-sys" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ - "bytes", - "itoa", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] -name = "http-body" -version = "0.4.6" +name = "json-patch" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", ] [[package]] -name = "http-body" -version = "1.0.1" +name = "jsonptr" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" dependencies = [ - "bytes", - "http 1.4.0", + "serde", + "serde_json", ] [[package]] -name = "http-body-util" -version = "0.1.3" +name = "jsonschema" +version = "0.46.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "6a5fe5206f06e589caf25e79fc05ccdf91fca745685fe9fe1a13bbdfb479a631" dependencies = [ - "bytes", - "futures-core", - "http 1.4.0", - "http-body 1.0.1", - "pin-project-lite", + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.18.0", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", ] [[package]] -name = "http-range-header" -version = "0.4.2" +name = "kasuari" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] [[package]] -name = "httparse" -version = "1.10.1" +name = "keyboard-types" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] [[package]] -name = "httpdate" -version = "1.0.3" +name = "keyring" +version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ - "typenum", + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", ] [[package]] -name = "hyper" -version = "0.14.32" +name = "khronos-egl" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", + "libc", + "pkg-config", ] [[package]] -name = "hyper" -version = "1.9.0" +name = "khronos_api" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] -name = "hyper-rustls" -version = "0.27.9" +name = "kitoken" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "e4663da0884a672d8705d339aad6367006e88df910bee436afdfbc898b2c7847" dependencies = [ - "http 1.4.0", - "hyper 1.9.0", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots 1.0.7", + "base64 0.22.1", + "bstr", + "derive_more", + "fancy-regex 0.18.0", + "hashbrown 0.17.1", + "log", + "memchr", + "multiversion", + "once_cell", + "orx-priority-queue", + "postcard", + "regex-automata", + "regex-syntax", + "sentencepiece-model", + "serde", + "serde_json", + "thiserror 2.0.18", + "unicode-normalization", ] [[package]] -name = "hyper-tls" -version = "0.5.0" +name = "kitten_tts_mini_rlx" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "4a075d2e356c458d9498490d65d77a0eedf87f50ea26280aa8a2e5ccc36b74fe" dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", + "anyhow", + "dirs", + "half", + "rlx-compile", + "rlx-cpu", + "rlx-ir", + "rlx-metal", + "rlx-mlx", + "rlx-onnx-import", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "hyper-tls" -version = "0.6.0" +name = "kittentts" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +checksum = "b625de8dfcd695628f97b347664692fe90fb850848ff9c5a535a173632531408" dependencies = [ - "bytes", - "http-body-util", - "hyper 1.9.0", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", + "anyhow", + "espeak-ng", + "fancy-regex 0.14.0", + "hf-hub 0.5.0", + "hound", + "once_cell", + "ort", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "zip 2.4.2", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "kqueue" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.9.0", - "ipnet", + "kqueue-sys", "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.3", - "system-configuration 0.7.0", - "tokio", - "tower-service", - "tracing", - "windows-registry", ] [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "kqueue-sys" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", + "bitflags 2.13.0", + "libc", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "lab" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" [[package]] -name = "ico" -version = "0.5.0" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" -dependencies = [ - "byteorder", - "png 0.17.16", -] +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "icu_collections" -version = "2.2.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke 0.8.2", - "zerofrom", - "zerovec", -] +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "lebe" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] -name = "icu_normalizer" -version = "2.2.0" +name = "libappindicator" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", ] [[package]] -name = "icu_normalizer_data" -version = "2.2.0" +name = "libappindicator-sys" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] [[package]] -name = "icu_properties" -version = "2.2.0" +name = "libbz2-rs-sys" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] -name = "icu_properties_data" -version = "2.2.0" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "icu_provider" -version = "2.2.0" +name = "libdbus-sys" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke 0.8.2", - "zerofrom", - "zerotrie", - "zerovec", + "pkg-config", ] [[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "identity-hash" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" - -[[package]] -name = "idna" -version = "1.1.0" +name = "libfuzzer-sys" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "arbitrary", + "cc", ] [[package]] -name = "idna_adapter" -version = "1.2.1" +name = "libloading" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" dependencies = [ - "icu_normalizer", - "icu_properties", + "cfg-if", + "winapi", ] [[package]] -name = "idun" -version = "0.0.3" +name = "libloading" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4870c15acd4691d274c8817b5987f968b8bf9b3be2ebeed3d1cfe14f1547c0" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "anyhow", - "base64 0.22.1", - "btleplug 0.11.8", - "env_logger", - "futures", - "log", - "serde", - "serde_json", - "tokio", - "tokio-tungstenite 0.24.0", - "uuid", + "cfg-if", + "windows-link 0.2.1", ] [[package]] -name = "igd-next" -version = "0.16.2" +name = "libloading" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ - "async-trait", - "attohttpc", - "bytes", - "futures", - "http 1.4.0", - "http-body-util", - "hyper 1.9.0", - "hyper-util", - "log", - "rand 0.9.4", - "tokio", - "url", - "xmltree", + "cfg-if", + "windows-link 0.2.1", ] [[package]] -name = "ignore" -version = "0.4.25" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "image" -version = "0.25.10" +name = "libredox" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png 0.18.1", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core", - "zune-jpeg", + "libc", ] [[package]] -name = "image-webp" -version = "0.2.4" +name = "libspa" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" dependencies = [ - "byteorder-lite", - "quick-error 2.0.1", + "bitflags 2.13.0", + "cc", + "convert_case 0.8.0", + "cookie-factory", + "libc", + "libspa-sys", + "nix 0.30.1", + "nom 8.0.0", + "system-deps 7.0.8", ] [[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - -[[package]] -name = "indexmap" -version = "1.9.3" +name = "libspa-sys" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", + "bindgen", + "cc", + "system-deps 7.0.8", ] [[package]] -name = "indexmap" -version = "2.14.0" +name = "libsqlite3-sys" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", + "cc", + "pkg-config", + "vcpkg", ] [[package]] -name = "indicatif" -version = "0.17.11" +name = "libudev" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" dependencies = [ - "console 0.15.11", - "number_prefix", - "portable-atomic", - "unicode-width 0.2.2", - "web-time", + "libc", + "libudev-sys", ] [[package]] -name = "indicatif" -version = "0.18.4" +name = "libudev-sys" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" dependencies = [ - "console 0.16.3", - "portable-atomic", - "unicode-width 0.2.2", - "unit-prefix", - "web-time", + "libc", + "pkg-config", ] [[package]] -name = "indoc" -version = "2.0.7" +name = "libusb1-sys" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" dependencies = [ - "rustversion", + "cc", + "libc", + "pkg-config", + "vcpkg", ] [[package]] -name = "infer" -version = "0.19.0" +name = "libwayshot-xcap" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "558a3a7ca16a17a14adf8f051b3adcd7766d397532f5f6d6a48034db11e54c22" dependencies = [ - "cfb", + "drm", + "gbm", + "gl", + "image", + "khronos-egl", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "tracing", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", ] [[package]] -name = "inotify" -version = "0.11.1" +name = "line-clipping" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.1", - "inotify-sys", - "libc", + "bitflags 2.13.0", ] [[package]] -name = "inotify-sys" -version = "0.1.5" +name = "linux-keyutils" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ + "bitflags 2.13.0", "libc", ] [[package]] -name = "inout" -version = "0.1.4" +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] -name = "instability" -version = "0.3.12" +name = "linux-raw-sys" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling 0.23.0", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] -name = "integer-encoding" -version = "3.0.4" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "interpolate_name" -version = "0.2.4" +name = "litemap" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] -name = "io-kit-sys" -version = "0.4.1" +name = "litrs" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "llmfit-core" +version = "0.9.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af704d274b3b4753ff3d680d9b140e2a0b3c2e0391abf4f29f17fcdfba0036b8" dependencies = [ - "core-foundation-sys", - "mach2 0.4.3", + "dirs", + "http 1.4.2", + "regex", + "serde", + "serde_json", + "serde_yml", + "sysinfo", + "ureq 3.3.0", + "which", ] [[package]] -name = "ipconfig" -version = "0.3.4" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "socket2 0.6.3", - "widestring", - "windows-registry", - "windows-result 0.4.1", - "windows-sys 0.61.2", + "scopeguard", ] [[package]] -name = "ipnet" -version = "2.12.0" +name = "log" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] -name = "iri-string" -version = "0.7.12" +name = "logos" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" dependencies = [ - "memchr", - "serde", + "logos-derive", ] [[package]] -name = "iroh" -version = "0.97.0" +name = "logos-codegen" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feb56e7e4b0ec7fba7efa6a236b016a52b5d927d50244aceb9e20566159b1a32" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" dependencies = [ - "backon", - "bytes", - "cfg_aliases", - "data-encoding", - "derive_more 2.1.1", - "ed25519-dalek", - "futures-util", - "getrandom 0.3.4", - "hickory-resolver", - "http 1.4.0", - "ipnet", - "iroh-base", - "iroh-metrics", - "iroh-relay", - "n0-error", - "n0-future", - "n0-watcher", - "netwatch", - "noq", - "noq-proto", - "noq-udp", - "papaya", - "pin-project", - "pkarr", - "pkcs8", - "portable-atomic", - "portmapper", - "rand 0.9.4", - "reqwest 0.12.28", - "rustc-hash 2.1.2", - "rustls", - "rustls-pki-types", - "rustls-webpki", - "serde", - "smallvec", - "strum 0.28.0", - "sync_wrapper 1.0.2", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "wasm-bindgen-futures", - "webpki-roots 1.0.7", + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.117", ] [[package]] -name = "iroh-base" -version = "0.97.0" +name = "logos-derive" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a354e3396b62c14717ee807dfee9a7f43f6dad47e4ac0fd1d49f1ffad14ef0" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" dependencies = [ - "curve25519-dalek", - "data-encoding", - "derive_more 2.1.1", - "digest 0.11.0-rc.10", - "ed25519-dalek", - "n0-error", - "rand_core 0.9.5", - "serde", - "sha2 0.11.0-rc.2", - "url", - "zeroize", - "zeroize_derive", + "logos-codegen", ] [[package]] -name = "iroh-example-client" -version = "0.1.0" +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" dependencies = [ - "anyhow", - "base32", - "iroh", - "iroh-base", - "serde", - "serde_json", - "skill-iroh", - "tempfile", - "tokio", - "totp-rs", - "url", + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", ] [[package]] -name = "iroh-metrics" -version = "0.38.3" +name = "loop9" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761b45ba046134b11eb3e432fa501616b45c4bf3a30c21717578bc07aa6461dd" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" dependencies = [ - "iroh-metrics-derive", - "itoa", - "n0-error", - "portable-atomic", - "postcard", - "ryu", - "serde", - "tracing", + "imgref", ] [[package]] -name = "iroh-metrics-derive" -version = "0.4.1" +name = "lru" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab063c2bfd6c3d5a33a913d4fdb5252f140db29ec67c704f20f3da7e8f92dbf" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", + "hashbrown 0.17.1", ] [[package]] -name = "iroh-relay" -version = "0.97.0" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d786b260cadfe82ae0b6a9e372e8c78949096a06c857d1c3521355cefced0f55" -dependencies = [ - "blake3", - "bytes", - "cfg_aliases", - "data-encoding", - "derive_more 2.1.1", - "getrandom 0.3.4", - "hickory-resolver", - "http 1.4.0", - "http-body-util", - "hyper 1.9.0", - "hyper-util", - "iroh-base", - "iroh-metrics", - "lru", - "n0-error", - "n0-future", - "noq", - "noq-proto", - "num_enum", - "pin-project", - "pkarr", - "postcard", - "rand 0.9.4", - "reqwest 0.12.28", - "rustls", - "rustls-pki-types", - "serde", - "serde_bytes", - "strum 0.28.0", - "tokio", - "tokio-rustls", - "tokio-util", - "tokio-websockets", - "tracing", - "url", - "vergen-gitcl", - "webpki-roots 1.0.7", - "ws_stream_wasm", - "z32", -] +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "iroh_test_client" +name = "luna-rs" version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7ea83f9eb94e4abb51a78206b8576ee70c990ac300b56c4fc54b625ab85258" dependencies = [ "anyhow", - "base32", + "bytemuck", "clap", - "env_logger", - "iroh", - "iroh-base", - "rand 0.10.1", + "exg", + "exg-luna", + "half", + "libm", + "ndarray 0.17.2", + "rayon", + "rlx", + "rustfft", + "safetensors 0.7.0", "serde", "serde_json", - "skill-iroh", - "tempfile", - "tokio", - "totp-rs", - "url", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", ] [[package]] -name = "is-terminal" -version = "0.4.17" +name = "lz4_flex" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", + "twox-hash", ] [[package]] -name = "is-wsl" -version = "0.4.0" +name = "lzma-rust2" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" dependencies = [ - "is-docker", - "once_cell", + "sha2 0.10.9", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "mac-addr" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" [[package]] -name = "itertools" -version = "0.13.0" +name = "mac-notification-sys" +version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "50efa634682b3fc5a1ab6f3dd5b2bce7b848011fc485b53b063dc68f2f74feae" dependencies = [ - "either", + "cc", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "time", ] [[package]] -name = "itertools" -version = "0.14.0" +name = "mac_address" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "either", + "nix 0.29.0", + "winapi", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "macaddr" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" [[package]] -name = "javascriptcore-rs" -version = "1.1.2" +name = "mach2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", + "libc", ] [[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" +name = "mach2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" dependencies = [ - "glib-sys", - "gobject-sys", "libc", - "system-deps 6.2.2", ] [[package]] -name = "jiff" -version = "0.2.24" +name = "macos-focus" +version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "5bbc91b86705c527663f01d126520b236146bf719440722787cba4a6d6b35e20" dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", + "libc", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", ] [[package]] -name = "jiff-static" -version = "0.2.24" +name = "macro_rules_attribute" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "macro_rules_attribute-proc_macro", + "paste", ] [[package]] -name = "jni" -version = "0.19.0" +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" dependencies = [ - "cesu8", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", + "libc", ] [[package]] -name = "jni" -version = "0.21.1" +name = "markup5ever" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", + "tendril", + "web_atoms", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "match_cfg" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" [[package]] -name = "jni-sys" -version = "0.4.1" +name = "matchers" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "jni-sys-macros", + "regex-automata", ] [[package]] -name = "jni-sys-macros" -version = "0.4.1" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.117", -] +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] -name = "jni-utils" -version = "0.1.1" +name = "matrixcompare" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259e9f2c3ead61de911f147000660511f07ab00adeed1d84f5ac4d0386e7a6c4" +checksum = "37832ba820e47c93d66b4360198dccb004b43c74abc3ac1ce1fed54e65a80445" dependencies = [ - "dashmap 5.5.3", - "futures", - "jni 0.19.0", - "log", - "once_cell", - "static_assertions", - "uuid", + "matrixcompare-core", + "num-traits", ] [[package]] -name = "jobserver" -version = "0.1.34" +name = "matrixcompare-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] +checksum = "b0bdabb30db18805d5290b3da7ceaccbddba795620b86c02145d688e04900a73" [[package]] -name = "js-sys" -version = "0.3.95" +name = "matrixmultiply" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" dependencies = [ - "cfg-if", - "futures-util", + "autocfg", + "num_cpus", "once_cell", - "wasm-bindgen", + "rawpointer", + "thread-tree", ] [[package]] -name = "json-patch" -version = "3.0.1" +name = "maybe-rayon" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 1.0.69", + "cfg-if", + "rayon", ] [[package]] -name = "jsonptr" -version = "0.6.3" +name = "memchr" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" -dependencies = [ - "serde", - "serde_json", -] +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] -name = "jsonschema" -version = "0.46.2" +name = "memmap2" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50180452e7808015fe083eae3efcf1ec98b89b45dd8cc204f7b4a6b7b81ea675" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ - "ahash", - "bytecount", - "data-encoding", - "email_address", - "fancy-regex 0.17.0", - "fraction", - "getrandom 0.3.4", - "idna", - "itoa", - "num-cmp", - "num-traits", - "percent-encoding", - "referencing", - "regex", - "regex-syntax", - "serde", - "serde_json", - "unicode-general-category", - "uuid-simd", + "libc", + "stable_deref_trait", ] [[package]] -name = "kasuari" -version = "0.4.12" +name = "memmem" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" [[package]] -name = "keyboard-types" -version = "0.7.0" +name = "memo-map" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.11.1", - "serde", - "unicode-segmentation", -] +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" [[package]] -name = "keyring" -version = "3.6.3" +name = "memoffset" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ - "byteorder", - "dbus-secret-service", - "linux-keyutils", - "log", - "secret-service", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", - "zeroize", + "autocfg", ] [[package]] -name = "khronos-egl" -version = "6.0.0" +name = "mendi" +version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +checksum = "e22f15003e7594bd1a24b0591408612d1b7bc191fe3f61e9191a18bd926fc03c" dependencies = [ - "libc", - "libloading 0.8.9", - "pkg-config", + "anyhow", + "btleplug 0.11.8", + "env_logger", + "futures", + "log", + "prost", + "rand 0.9.4", + "tokio", + "uuid", ] [[package]] -name = "khronos_api" -version = "3.1.0" +name = "metal" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.0", + "block", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] [[package]] -name = "kittentts" -version = "0.4.1" +name = "metal" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b625de8dfcd695628f97b347664692fe90fb850848ff9c5a535a173632531408" +checksum = "9c3572083504c43e14aec05447f8a3d57cce0f66d7a3c1b9058572eca4d70ab9" dependencies = [ - "anyhow", - "espeak-ng", - "fancy-regex 0.14.0", - "hf-hub 0.5.0", - "hound", - "once_cell", - "ort", - "regex", - "serde", - "serde_json", - "thiserror 1.0.69", - "zip 2.4.2", + "bitflags 2.13.0", + "block", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "log", + "objc", + "paste", ] [[package]] -name = "kqueue" -version = "1.1.1" +name = "micromap" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" [[package]] -name = "kqueue-sys" -version = "1.0.4" +name = "miette" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ - "bitflags 1.3.2", - "libc", + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", ] [[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" +name = "miette-derive" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.14.0", - "selectors 0.24.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "lebe" -version = "0.5.3" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "libappindicator" -version = "0.9.0" +name = "mime_guess" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", + "mime", + "unicase", ] [[package]] -name = "libappindicator-sys" -version = "0.9.0" +name = "minijinja" +version = "2.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +checksum = "2929e494b2280e1e18959bb2e121da03347ae896896fdfaceaab43c88a02803f" dependencies = [ - "gtk-sys", - "libloading 0.7.4", - "once_cell", + "memo-map", + "serde", ] [[package]] -name = "libbz2-rs-sys" -version = "0.2.3" +name = "minimal-lexical" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] -name = "libc" -version = "0.2.186" +name = "minisign-verify" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" [[package]] -name = "libdbus-sys" -version = "0.2.7" +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "pkg-config", + "adler2", + "simd-adler32", ] [[package]] -name = "libfuzzer-sys" -version = "0.4.12" +name = "mio" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ - "arbitrary", - "cc", + "libc", + "log", + "wasi", + "windows-sys 0.61.2", ] [[package]] -name = "libloading" -version = "0.7.4" +name = "moka" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ - "cfg-if", - "winapi", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", ] [[package]] -name = "libloading" -version = "0.8.9" +name = "monostate" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" dependencies = [ - "cfg-if", - "windows-link 0.2.1", + "monostate-impl", + "serde", + "serde_core", ] [[package]] -name = "liblzma" -version = "0.4.6" +name = "monostate-impl" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ - "liblzma-sys", - "num_cpus", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "liblzma-sys" -version = "0.4.6" +name = "moxcms" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ - "cc", - "libc", - "pkg-config", + "num-traits", + "pxfm", ] [[package]] -name = "libm" -version = "0.2.16" +name = "muda" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] [[package]] -name = "libredox" -version = "0.1.16" +name = "multimap" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags 2.11.1", - "libc", - "plain", - "redox_syscall 0.7.4", -] +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] -name = "libspa" -version = "0.9.2" +name = "multiversion" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" +checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" dependencies = [ - "bitflags 2.11.1", - "cc", - "convert_case 0.8.0", - "cookie-factory", - "libc", - "libspa-sys", - "nix 0.30.1", - "nom 8.0.0", - "system-deps 7.0.8", + "multiversion-macros", + "target-features", ] [[package]] -name = "libspa-sys" -version = "0.9.2" +name = "multiversion-macros" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" +checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" dependencies = [ - "bindgen 0.72.1", - "cc", - "system-deps 7.0.8", + "proc-macro2", + "quote", + "syn 2.0.117", + "target-features", ] [[package]] -name = "libsqlite3-sys" -version = "0.37.0" +name = "muse-rs" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "e8573c4cf142f2571ba34f5db2824003dcb6711fd9894b2a4fae4795cc041b4a" dependencies = [ - "cc", - "pkg-config", - "vcpkg", + "anyhow", + "btleplug 0.11.8", + "env_logger", + "futures", + "log", + "serde", + "serde_json", + "tokio", + "uuid", ] [[package]] -name = "libudev" -version = "0.3.0" +name = "mw75" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +checksum = "8886e4b7b1a83160427e4820ecb22980b91586ea8257a9070ae02dcf8254b60c" dependencies = [ + "anyhow", + "bluer", + "btleplug 0.11.8", + "crossterm", + "env_logger", + "futures", "libc", - "libudev-sys", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-io-bluetooth", + "rand 0.9.4", + "ratatui", + "serde", + "serde_json", + "tokio", + "uuid", + "windows 0.62.2", ] [[package]] -name = "libudev-sys" -version = "0.1.4" +name = "n0-error" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" dependencies = [ - "libc", - "pkg-config", + "n0-error-macros", + "spez", ] [[package]] -name = "libusb1-sys" -version = "0.7.0" +name = "n0-error-macros" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "libwayshot-xcap" +name = "n0-future" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558a3a7ca16a17a14adf8f051b3adcd7766d397532f5f6d6a48034db11e54c22" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" dependencies = [ - "drm", - "gbm", - "gl", - "image", - "khronos-egl", - "memmap2", - "rustix 1.1.4", - "thiserror 2.0.18", - "tracing", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", ] [[package]] -name = "line-clipping" -version = "0.3.7" +name = "n0-watcher" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "928d8039a66cce5efcfd35e88b32d3defc8eba630b3ac451522997f563956a52" dependencies = [ - "bitflags 2.11.1", + "derive_more", + "n0-error", + "n0-future", ] [[package]] -name = "linux-keyutils" -version = "0.2.5" +name = "naga" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" dependencies = [ - "bitflags 2.11.1", - "libc", + "arrayvec", + "bit-set 0.9.1", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap 2.14.0", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "litrs" -version = "1.0.0" +name = "nalgebra" +version = "0.32.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "rand 0.8.6", + "rand_distr 0.4.3", + "simba", + "typenum", +] [[package]] -name = "llama-cpp-4" -version = "0.2.50" +name = "nalgebra-macros" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcf0cd079ad2f022bf031670df8ba456c21912563e820aa88e7102e33afb194" +checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" dependencies = [ - "enumflags2", - "llama-cpp-sys-4", - "thiserror 2.0.18", - "tracing", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "llama-cpp-sys-4" -version = "0.2.50" +name = "nano-gemm" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca95ff4c86ec27cba44c939e7161bc66a7edef083f7e59186e3bf4e5778a76a" +checksum = "bb5ba2bea1c00e53de11f6ab5bd0761ba87dc0045d63b0c87ee471d2d3061376" dependencies = [ - "bindgen 0.72.1", - "cc", - "cmake", - "glob", - "winreg 0.56.0", + "equator 0.2.2", + "nano-gemm-c32", + "nano-gemm-c64", + "nano-gemm-codegen", + "nano-gemm-core", + "nano-gemm-f32", + "nano-gemm-f64", + "num-complex", ] [[package]] -name = "llmfit-core" -version = "0.9.14" +name = "nano-gemm-c32" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e0d5d303daeb9a26f414d6e97cc123fa924314177bba06b4dd03b65a2313f0" +checksum = "a40449e57a5713464c3a1208c4c3301c8d29ee1344711822cf022bc91373a91b" dependencies = [ - "http 1.4.0", - "serde", - "serde_json", - "sysinfo 0.38.4", - "ureq 3.3.0", - "which", + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", ] [[package]] -name = "lock_api" -version = "0.4.14" +name = "nano-gemm-c64" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "743a6e6211358fba85d1009616751e4107da86f4c95b24e684ce85f25c25b3bf" dependencies = [ - "scopeguard", + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", ] [[package]] -name = "log" -version = "0.4.29" +name = "nano-gemm-codegen" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "963bf7c7110d55430169dc74c67096375491ed580cd2ef84842550ac72e781fa" [[package]] -name = "loom" -version = "0.7.2" +name = "nano-gemm-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] +checksum = "fe3fc4f83ae8861bad79dc3c016bd6b0220da5f9de302e07d3112d16efc24aa6" [[package]] -name = "loop9" -version = "0.1.5" +name = "nano-gemm-f32" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +checksum = "4e3681b7ce35658f79da94b7f62c60a005e29c373c7111ed070e3bf64546a8bb" dependencies = [ - "imgref", + "nano-gemm-codegen", + "nano-gemm-core", ] [[package]] -name = "lru" -version = "0.16.4" +name = "nano-gemm-f64" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "bc1e619ed04d801809e1f63e61b669d380c4119e8b0cdd6ed184c6b111f046d8" dependencies = [ - "hashbrown 0.16.1", + "nano-gemm-codegen", + "nano-gemm-core", ] [[package]] -name = "lru-slab" -version = "0.1.2" +name = "native-tls" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] [[package]] -name = "luna-rs" -version = "0.0.3" +name = "ndarray" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d50e0aacebb90506938281064ebc240859ae6835b2d4d430871c4e6becbec69a" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" dependencies = [ - "anyhow", - "burn", - "burn-ndarray", - "bytemuck", - "clap", - "exg", - "exg-luna", - "half", - "ndarray 0.17.2", - "rustfft", - "safetensors 0.7.0", - "serde", - "serde_json", + "cblas-sys", + "libc", + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", + "rayon", ] [[package]] -name = "lz4_flex" -version = "0.11.6" +name = "ndarray" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" dependencies = [ - "twox-hash", + "cblas-sys", + "libc", + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", + "rayon", ] [[package]] -name = "lzma-rust2" -version = "0.15.7" +name = "ndk" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "crc", - "sha2 0.10.9", + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", ] [[package]] -name = "mac" +name = "ndk-context" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] -name = "mac-addr" -version = "0.3.0" +name = "ndk-sys" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] [[package]] -name = "mac-notification-sys" -version = "0.6.12" +name = "netdev" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" dependencies = [ - "cc", - "objc2 0.6.4", + "block2 0.6.2", + "dispatch2", + "dlopen2", + "ipnet", + "libc", + "mac-addr", + "netlink-packet-core", + "netlink-packet-route 0.29.0", + "netlink-sys", + "objc2-core-foundation", + "objc2-core-wlan", "objc2-foundation 0.3.2", - "time", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", ] [[package]] -name = "mac_address" -version = "1.1.8" +name = "netlink-packet-core" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" dependencies = [ - "nix 0.29.0", - "winapi", + "paste", ] [[package]] -name = "macaddr" -version = "1.0.1" +name = "netlink-packet-route" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" - -[[package]] -name = "macerator" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b0b2dbe8b22f9e96ba12e29964889010117f92e6bd006010887320ae58e2f0" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" dependencies = [ - "bytemuck", - "cfg_aliases", - "half", - "macerator-macros", - "moddef", - "num-traits", - "paste", - "rustc_version", + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", ] [[package]] -name = "macerator-macros" -version = "0.1.5" +name = "netlink-packet-route" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5ce85961d618ce9794bdf822bfe96fe9dd341aa5b033b454f7a8d96e79b9b1" +checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092" dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", ] [[package]] -name = "mach-sys" -version = "0.5.4" +name = "netlink-proto" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48460c2e82a3a0de197152fdf8d2c2d5e43adc501501553e439bf2156e6f87c7" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" dependencies = [ - "fastrand", + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", ] [[package]] -name = "mach2" -version = "0.4.3" +name = "netlink-sys" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ + "bytes", + "futures-util", "libc", + "log", + "tokio", ] [[package]] -name = "mach2" -version = "0.5.0" +name = "netwatch" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +checksum = "2071e0c2b5b229622c459096b84f1ad51afa150cdeeefdad491ef3704e581d91" dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "js-sys", "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.30.0", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.4", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", ] [[package]] -name = "macos-focus" +name = "neurofield" version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bbc91b86705c527663f01d126520b236146bf719440722787cba4a6d6b35e20" +checksum = "c13eaa73c77084513832d5f142cd4b5f5e8cc8fa0d8078213e94b40a15a19210" dependencies = [ - "libc", - "serde", - "serde_json", - "thiserror 1.0.69", - "uuid", + "env_logger", + "libloading 0.8.9", + "log", + "thiserror 2.0.18", ] [[package]] -name = "macro_rules_attribute" -version = "0.2.2" +name = "neurorvq-rs" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +checksum = "dd07dc38966199465efe54a82bfe066e96cc256e00c0bd8e3f0637b69c10bbc5" dependencies = [ - "macro_rules_attribute-proc_macro", - "paste", + "anyhow", + "bytemuck", + "clap", + "half", + "libm", + "ndarray 0.16.1", + "rayon", + "rlx", + "safetensors 0.7.0", + "serde", + "serde_json", + "serde_yaml", ] [[package]] -name = "macro_rules_attribute-proc_macro" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" - -[[package]] -name = "malloc_buf" -version = "0.0.6" +name = "neurosity" +version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +checksum = "1eff82cf68a76b6bffb3dbada16100d404ce17b254d2599b866c44439be502be" dependencies = [ + "env_logger", "libc", -] - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq 2.12.1", ] [[package]] -name = "markup5ever" -version = "0.38.0" +name = "neurosky" +version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "a77b2f902d736135d528ef4af4767cf2099d8a64da6ab2146093b446ac084b80" dependencies = [ + "env_logger", + "libc", "log", - "tendril 0.5.0", - "web_atoms", + "serialport", + "thiserror 2.0.18", ] [[package]] -name = "match_cfg" -version = "0.1.0" +name = "new_debug_unreachable" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] -name = "match_token" -version = "0.1.0" +name = "nix" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "bitflags 1.3.2", + "cfg-if", + "libc", ] [[package]] -name = "matchers" -version = "0.2.0" +name = "nix" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "regex-automata", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", ] [[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "matrixmultiply" -version = "0.3.10" +name = "nix" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "autocfg", - "num_cpus", - "once_cell", - "rawpointer", - "thread-tree", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", ] [[package]] -name = "maybe-rayon" -version = "0.1.1" +name = "nix" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ + "bitflags 2.13.0", "cfg-if", - "rayon", + "cfg_aliases", + "libc", ] [[package]] -name = "md5" -version = "0.8.0" +name = "no_std_io2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] [[package]] -name = "memchr" -version = "2.8.0" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] [[package]] -name = "memmap2" -version = "0.9.10" +name = "nom" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ - "libc", - "stable_deref_trait", + "memchr", ] [[package]] -name = "memmem" -version = "0.1.1" +name = "noop_proc_macro" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] -name = "memoffset" -version = "0.9.1" +name = "noq" +version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +checksum = "198b99fc085a5db1f7d259edb5ede8311e59f28cdd2687920b4313613d21a73f" dependencies = [ - "autocfg", + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash 2.1.2", + "rustls", + "socket2 0.6.4", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", ] [[package]] -name = "mendi" -version = "0.0.2" +name = "noq-proto" +version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22f15003e7594bd1a24b0591408612d1b7bc191fe3f61e9191a18bd926fc03c" +checksum = "1ab0ac774795ce1e42a7e61266e71f3be8110210630441169ac8dda403dd23f1" dependencies = [ - "anyhow", - "btleplug 0.11.8", - "env_logger", - "futures", - "log", - "prost", - "rand 0.9.4", - "tokio", - "uuid", + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", ] [[package]] -name = "metal" -version = "0.29.0" +name = "noq-udp" +version = "1.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +checksum = "b3c1520eacd33fd6b009e2e70116b05508ade51db5e0d315ff8bf6b702148c2b" dependencies = [ - "bitflags 2.11.1", - "block", - "core-graphics-types 0.1.3", - "foreign-types 0.5.0", - "log", - "objc", - "paste", + "cfg_aliases", + "libc", + "socket2 0.6.4", + "tracing", + "windows-sys 0.61.2", ] [[package]] -name = "metal" -version = "0.32.0" +name = "notify" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", - "block", - "core-graphics-types 0.2.0", - "foreign-types 0.5.0", + "bitflags 2.13.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", "log", - "objc", - "paste", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", ] [[package]] -name = "micromap" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" +name = "notify-rust" +version = "4.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" dependencies = [ - "mime", - "unicase", + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus 5.16.0", ] [[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "minisign-verify" -version = "0.2.5" +name = "notify-types" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "noyalib" +version = "0.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "e493c05128df7a83b9676b709d590e0ebc285c7ed3152bc679668e8c1e506af5" dependencies = [ - "adler2", - "simd-adler32", + "indexmap 2.14.0", + "memchr", + "rustc-hash 2.1.2", + "serde", + "smallvec", ] [[package]] -name = "mio" -version = "1.2.0" +name = "npyz" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "9f0e759e014e630f90af745101b614f761306ddc541681e546649068e25ec1b9" dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", + "byteorder", + "num-bigint", + "py_literal", ] [[package]] -name = "mlx-internal-macros-burn" -version = "0.25.5" +name = "ntapi" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e15434c8327ec279c5b9ee7d8bfd2163f2a1f48c2c86cedfa7bba056ef98df25" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ - "darling 0.21.3", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.117", + "winapi", ] [[package]] -name = "mlx-macros-burn" -version = "0.25.5" +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b4f051a8e3d0e8b1c3693a6418705311f4766b6d0f25347566f74c0f16ab56" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "windows-sys 0.61.2", ] [[package]] -name = "mlx-rs-burn" -version = "0.25.5" +name = "num" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01731a3240ba4d5fee76289dd915f34b26969689e492e1a18fe22c002fdd6e31" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "dyn-clone", - "half", - "itertools 0.14.0", - "libc", - "mach-sys", - "mlx-internal-macros-burn", - "mlx-macros-burn", - "mlx-sys-burn", + "num-bigint", "num-complex", + "num-integer", + "num-iter", + "num-rational", "num-traits", - "num_enum", - "parking_lot", - "paste", - "smallvec", - "strum 0.27.2", - "thiserror 2.0.18", ] [[package]] -name = "mlx-sys-burn" -version = "0.2.2" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45de2dde1a1dd946d07fdc2711907ebf0b968dde7b6e58d4ed729655788cc8fc" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "bindgen 0.72.1", - "cc", - "cmake", + "num-integer", + "num-traits", ] [[package]] -name = "moddef" -version = "0.3.0" +name = "num-cmp" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0b3262dc837d2513fe2ef31ff8461352ef932dcca31ba0c0abe33547cf6b9b" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" [[package]] -name = "moka" -version = "0.12.15" +name = "num-complex" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", + "bytemuck", + "num-traits", + "rand 0.8.6", ] [[package]] -name = "monostate" -version = "0.1.18" +name = "num-conv" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" -dependencies = [ - "monostate-impl", - "serde", - "serde_core", -] +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] -name = "monostate-impl" -version = "0.1.18" +name = "num-derive" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", @@ -7843,3310 +7637,3814 @@ dependencies = [ ] [[package]] -name = "moxcms" -version = "0.8.1" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", - "pxfm", ] [[package]] -name = "muda" -version = "0.17.2" +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2 0.6.4", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "png 0.17.16", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "autocfg", + "num-integer", + "num-traits", ] [[package]] -name = "multer" -version = "3.1.0" +name = "num-rational" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http 1.4.0", - "httparse", - "memchr", - "mime", - "spin 0.9.8", - "version_check", + "num-bigint", + "num-integer", + "num-traits", ] [[package]] -name = "muse-rs" -version = "0.1.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8573c4cf142f2571ba34f5db2824003dcb6711fd9894b2a4fae4795cc041b4a" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "anyhow", - "btleplug 0.11.8", - "env_logger", - "futures", - "log", - "serde", - "serde_json", - "tokio", - "uuid", + "autocfg", + "libm", ] [[package]] -name = "mw75" -version = "0.0.6" +name = "num_cpus" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8886e4b7b1a83160427e4820ecb22980b91586ea8257a9070ae02dcf8254b60c" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "anyhow", - "bluer", - "btleplug 0.11.8", - "crossterm", - "env_logger", - "futures", + "hermit-abi", "libc", - "log", - "objc2 0.6.4", - "objc2-foundation 0.3.2", - "objc2-io-bluetooth", - "rand 0.9.4", - "ratatui", - "serde", - "serde_json", - "tokio", - "uuid", - "windows 0.62.2", ] [[package]] -name = "n0-error" -version = "0.1.3" +name = "num_enum" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ - "n0-error-macros", - "spez", + "num_enum_derive", + "rustversion", ] [[package]] -name = "n0-error-macros" -version = "0.1.3" +name = "num_enum_derive" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] -name = "n0-future" -version = "0.3.2" +name = "num_threads" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ - "cfg_aliases", - "derive_more 2.1.1", - "futures-buffered", - "futures-lite", - "futures-util", - "js-sys", - "pin-project", - "send_wrapper", - "tokio", - "tokio-util", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-time", + "libc", ] [[package]] -name = "n0-watcher" -version = "0.6.1" +name = "number_prefix" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38795f7932e6e9d1c6e989270ef5b3ff24ebb910e2c9d4bed2d28d8bae3007dc" -dependencies = [ - "derive_more 2.1.1", - "n0-error", - "n0-future", -] +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] -name = "naga" -version = "26.0.0" +name = "objc" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916cbc7cb27db60be930a4e2da243cf4bc39569195f22fd8ee419cd31d5b662c" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ - "arrayvec", - "bit-set 0.8.0", - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.15.5", - "hexf-parse", - "indexmap 2.14.0", - "libm", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "spirv", - "thiserror 2.0.18", - "unicode-ident", + "malloc_buf", ] [[package]] -name = "native-tls" -version = "0.2.18" +name = "objc-sys" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 3.7.0", - "security-framework-sys", - "tempfile", -] +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] -name = "nb" -version = "0.1.3" +name = "objc2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" dependencies = [ - "nb 1.1.0", + "objc-sys", + "objc2-encode", ] [[package]] -name = "nb" -version = "1.1.0" +name = "objc2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] [[package]] -name = "ndarray" -version = "0.16.1" +name = "objc2-app-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core", ] [[package]] -name = "ndarray" -version = "0.17.2" +name = "objc2-audio-toolbox" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "cblas-sys", + "bitflags 2.13.0", "libc", - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", - "rayon", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] -name = "ndk" -version = "0.9.0" +name = "objc2-av-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" dependencies = [ - "bitflags 2.11.1", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", + "bitflags 2.13.0", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-avf-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-image-io", + "objc2-media-toolbox", + "objc2-quartz-core", ] [[package]] -name = "ndk-context" -version = "0.1.1" +name = "objc2-avf-audio" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] [[package]] -name = "ndk-sys" -version = "0.6.0+11769913" +name = "objc2-cloud-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "jni-sys 0.3.1", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] -name = "netdev" -version = "0.40.1" +name = "objc2-core-audio" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ - "block2 0.6.2", "dispatch2", - "dlopen2 0.5.0", - "ipnet", - "libc", - "mac-addr", - "netlink-packet-core", - "netlink-packet-route", - "netlink-sys", + "objc2 0.6.4", + "objc2-core-audio-types", "objc2-core-foundation", - "objc2-system-configuration", - "once_cell", - "plist", - "windows-sys 0.59.0", + "objc2-foundation 0.3.2", ] [[package]] -name = "netlink-packet-core" -version = "0.8.1" +name = "objc2-core-audio-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "paste", + "bitflags 2.13.0", + "objc2 0.6.4", ] [[package]] -name = "netlink-packet-route" -version = "0.29.0" +name = "objc2-core-bluetooth" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111" dependencies = [ - "bitflags 2.11.1", - "libc", - "log", - "netlink-packet-core", + "bitflags 2.13.0", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "netlink-proto" -version = "0.12.0" +name = "objc2-core-data" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bytes", - "futures", - "log", - "netlink-packet-core", - "netlink-sys", - "thiserror 2.0.18", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] -name = "netlink-sys" -version = "0.8.8" +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bytes", - "futures-util", + "bitflags 2.13.0", + "block2 0.6.2", + "dispatch2", "libc", - "log", - "tokio", + "objc2 0.6.4", ] [[package]] -name = "netwatch" -version = "0.15.0" +name = "objc2-core-graphics" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1b27babe89ef9f2237bc6c028bea24fa84163a1b6f8f17ff93573ebd7d861f" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "atomic-waker", - "bytes", - "cfg_aliases", - "derive_more 2.1.1", - "js-sys", + "bitflags 2.13.0", + "block2 0.6.2", + "dispatch2", "libc", - "n0-error", - "n0-future", - "n0-watcher", - "netdev", - "netlink-packet-core", - "netlink-packet-route", - "netlink-proto", - "netlink-sys", - "noq-udp", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-system-configuration", - "pin-project-lite", - "serde", - "socket2 0.6.3", - "time", - "tokio", - "tokio-util", - "tracing", - "web-sys", - "windows 0.62.2", - "windows-result 0.4.1", - "wmi", + "objc2-io-surface", + "objc2-metal", ] [[package]] -name = "neurofield" -version = "0.0.1" +name = "objc2-core-image" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13eaa73c77084513832d5f142cd4b5f5e8cc8fa0d8078213e94b40a15a19210" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "env_logger", - "libloading 0.8.9", - "log", - "thiserror 2.0.18", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] -name = "neurorvq" -version = "0.1.0" +name = "objc2-core-location" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94cbbb93c9a21e0c9d15b318d85aee5df10b6c898bf67e380b387d2e2ade7b88" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "anyhow", - "burn", - "burn-ndarray", - "burn-wgpu", - "bytemuck", - "clap", - "half", - "safetensors 0.7.0", - "serde", - "serde_json", - "serde_yaml", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] -name = "neurosity" -version = "0.0.1" +name = "objc2-core-media" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1eff82cf68a76b6bffb3dbada16100d404ce17b254d2599b866c44439be502be" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ - "env_logger", - "libc", - "log", - "serde", - "serde_json", - "thiserror 2.0.18", - "ureq 2.12.1", + "bitflags 2.13.0", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", ] [[package]] -name = "neurosky" -version = "0.0.1" +name = "objc2-core-text" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77b2f902d736135d528ef4af4767cf2099d8a64da6ab2146093b446ac084b80" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "env_logger", - "libc", - "log", - "serialport", - "thiserror 2.0.18", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", ] [[package]] -name = "neutts" -version = "0.1.1" +name = "objc2-core-video" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95569110f3d2af0100f7d5094880c28827ab844a8f579bd2b8a9a9beef704073" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "anyhow", - "burn", - "dirs", - "espeak-ng", - "fancy-regex 0.14.0", - "hf-hub 0.5.0", - "hound", - "llama-cpp-4", - "memmap2", - "ndarray 0.16.1", - "once_cell", - "rand 0.8.6", - "regex", - "rustfft", - "safetensors 0.5.3", - "serde", - "serde_json", - "sha2 0.10.9", - "thiserror 1.0.69", - "zip 2.4.2", + "bitflags 2.13.0", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", + "objc2-metal", ] [[package]] -name = "new_debug_unreachable" -version = "1.0.6" +name = "objc2-core-wlan" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", + "objc2-security-foundation", +] [[package]] -name = "nix" -version = "0.26.4" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", + "cc", ] [[package]] -name = "nix" -version = "0.29.0" +name = "objc2-foundation" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", + "bitflags 2.13.0", + "block2 0.5.1", "libc", - "memoffset", + "objc2 0.5.2", ] [[package]] -name = "nix" -version = "0.30.1" +name = "objc2-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", + "bitflags 2.13.0", + "block2 0.6.2", "libc", + "objc2 0.6.4", + "objc2-core-foundation", ] [[package]] -name = "nix" -version = "0.31.2" +name = "objc2-image-io" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", - "libc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", ] [[package]] -name = "no_std_io2" -version = "0.9.3" +name = "objc2-io-bluetooth" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +checksum = "558b52e7b77f6a68e1e2cc700ff2266779ede47bacb260a33d363b9a623cc761" dependencies = [ - "memchr", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] -name = "nodrop" -version = "0.1.14" +name = "objc2-io-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] [[package]] -name = "nom" -version = "7.1.3" +name = "objc2-io-surface" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "memchr", - "minimal-lexical", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", ] [[package]] -name = "nom" -version = "8.0.0" +name = "objc2-media-toolbox" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" dependencies = [ - "memchr", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-media", ] [[package]] -name = "noop_proc_macro" -version = "0.3.0" +name = "objc2-metal" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] [[package]] -name = "noq" -version = "0.17.0" +name = "objc2-osa-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df966fb44ac763bc86da97fa6c811c54ae82ef656575949f93c6dae0c9f09bf" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bytes", - "cfg_aliases", - "noq-proto", - "noq-udp", - "pin-project-lite", - "rustc-hash 2.1.2", - "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "web-time", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", ] [[package]] -name = "noq-proto" -version = "0.16.0" +name = "objc2-quartz-core" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c61b72abd670eebc05b5cf720e077b04a3ef3354bc7bc19f1c3524cb424db7b" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "aes-gcm", - "bytes", - "derive_more 2.1.1", - "enum-assoc", - "fastbloom", - "getrandom 0.3.4", - "identity-hash", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash 2.1.2", - "rustls", - "rustls-pki-types", - "slab", - "sorted-index-buffer", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal", ] [[package]] -name = "noq-udp" -version = "0.9.0" +name = "objc2-security" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb9be4fedd6b98f3ba82ccd3506f4d0219fb723c3f97c67e12fe1494aa020e44" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "cfg_aliases", - "libc", - "socket2 0.6.3", - "tracing", - "windows-sys 0.61.2", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", ] [[package]] -name = "notify" -version = "8.2.0" +name = "objc2-security-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" dependencies = [ - "bitflags 2.11.1", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] -name = "notify-rust" -version = "4.16.0" +name = "objc2-system-configuration" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus 5.14.0", + "bitflags 2.13.0", + "dispatch2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-security", ] [[package]] -name = "notify-types" -version = "2.1.0" +name = "objc2-ui-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core", + "objc2-user-notifications", ] [[package]] -name = "ntapi" -version = "0.4.3" +name = "objc2-user-notifications" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "winapi", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] -name = "ntimestamp" -version = "1.0.0" +name = "objc2-web-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "base32", - "document-features", - "getrandom 0.2.17", - "httpdate", - "js-sys", - "once_cell", - "serde", + "bitflags 2.13.0", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "object" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ - "windows-sys 0.61.2", + "memchr", ] [[package]] -name = "num" -version = "0.4.3" +name = "object" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", + "memchr", ] [[package]] -name = "num-bigint" -version = "0.4.6" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ - "num-integer", - "num-traits", + "critical-section", + "portable-atomic", ] [[package]] -name = "num-cmp" -version = "0.1.0" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "num-complex" -version = "0.4.6" +name = "onig" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bytemuck", - "num-traits", + "bitflags 2.13.0", + "libc", + "once_cell", + "onig_sys", ] [[package]] -name = "num-conv" -version = "0.2.1" +name = "onig_sys" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] [[package]] -name = "num-derive" -version = "0.4.2" +name = "onnx" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +checksum = "b8973d1dd0205734e9a3400fa1e59b0d013f4d71531595f71fcf76ab0ff1d35d" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "protobuf", + "protoc-rust", ] [[package]] -name = "num-integer" -version = "0.1.46" +name = "oorandom" +version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] -name = "num-iter" -version = "0.1.45" +name = "opaque-debug" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ - "autocfg", - "num-integer", - "num-traits", + "dunce", + "is-wsl", + "libc", + "pathdiff", ] [[package]] -name = "num-rational" -version = "0.4.2" +name = "openbci" +version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +checksum = "e4bb76c1335251e20501eae68868b0e8d8f71ee54f9ff1fd420a0ebdd2c2090f" dependencies = [ - "num-bigint", - "num-integer", - "num-traits", + "btleplug 0.11.8", + "futures", + "log", + "serde", + "serde_json", + "serialport", + "thiserror 1.0.69", + "tokio", + "ureq 2.12.1", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "openblas-build" +version = "0.10.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "bb9c85e9e7dd5acdc67b9f3f0c99656b550df716bc63540c6a224a920754a5c2" dependencies = [ - "autocfg", - "libm", + "anyhow", + "cc", + "flate2", + "tar", + "thiserror 2.0.18", + "ureq 3.3.0", ] [[package]] -name = "num_cpus" -version = "1.17.0" +name = "openblas-src" +version = "0.10.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +checksum = "f1a81a5e467f1861ad6ac32d5ec1690ad097d19854753b7424250fe27da46b98" dependencies = [ - "hermit-abi", - "libc", + "dirs", + "openblas-build", + "pkg-config", + "vcpkg", ] [[package]] -name = "num_enum" -version = "0.7.6" +name = "openssl" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "num_enum_derive", - "rustversion", + "bitflags 2.13.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", ] [[package]] -name = "num_enum_derive" -version = "0.7.6" +name = "openssl-macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ - "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] -name = "num_threads" -version = "0.1.7" +name = "openssl-probe" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - -[[package]] -name = "objc" -version = "0.2.7" +name = "openssl-sys" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ - "malloc_buf", + "cc", + "libc", + "pkg-config", + "vcpkg", ] [[package]] -name = "objc-sys" -version = "0.3.5" +name = "option-ext" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "objc2" -version = "0.5.2" +name = "ordered-float" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" dependencies = [ - "objc-sys", - "objc2-encode", + "num-traits", ] [[package]] -name = "objc2" -version = "0.6.4" +name = "ordered-float" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ - "objc2-encode", - "objc2-exception-helper", + "num-traits", ] [[package]] -name = "objc2-app-kit" -version = "0.3.2" +name = "ordered-float" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "libc", - "objc2 0.6.4", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation 0.3.2", - "objc2-quartz-core", + "num-traits", ] [[package]] -name = "objc2-audio-toolbox" -version = "0.3.2" +name = "ordered-stream" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ - "bitflags 2.11.1", - "libc", - "objc2 0.6.4", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-foundation 0.3.2", + "futures-core", + "pin-project-lite", ] [[package]] -name = "objc2-av-foundation" -version = "0.3.2" +name = "ort" +version = "2.0.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "dispatch2", - "objc2 0.6.4", - "objc2-avf-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-video", - "objc2-foundation 0.3.2", - "objc2-image-io", - "objc2-media-toolbox", - "objc2-quartz-core", + "ndarray 0.17.2", + "ort-sys", + "smallvec", + "tracing", + "ureq 3.3.0", ] [[package]] -name = "objc2-avf-audio" -version = "0.3.2" +name = "ort-sys" +version = "2.0.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", + "hmac-sha256", + "lzma-rust2", + "pkg-config", + "ureq 3.3.0", ] [[package]] -name = "objc2-cloud-kit" -version = "0.3.2" +name = "orx-priority-queue" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] +checksum = "a89722d987db848624cf8fca21245d59cf6382b01b4ca79ae70261cea5747495" [[package]] -name = "objc2-core-audio" -version = "0.3.2" +name = "osakit" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "dispatch2", "objc2 0.6.4", - "objc2-core-audio-types", - "objc2-core-foundation", "objc2-foundation 0.3.2", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", ] [[package]] -name = "objc2-core-audio-types" -version = "0.3.2" +name = "oura-api" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +checksum = "bc7b1465d5e8f24dc7d5681665af3793d1d83d764c525353bc8afa10339989af" dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", + "paste", + "reqwest 0.11.27", + "serde", + "typed-builder", ] [[package]] -name = "objc2-core-bluetooth" -version = "0.2.2" +name = "outref" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" dependencies = [ - "bitflags 2.11.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "libc", + "winapi", ] [[package]] -name = "objc2-core-data" -version = "0.3.2" +name = "palette" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", - "objc2-foundation 0.3.2", + "approx", + "fast-srgb8", + "libm", + "palette_derive", ] [[package]] -name = "objc2-core-foundation" -version = "0.3.2" +name = "palette_derive" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "dispatch2", - "libc", - "objc2 0.6.4", + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "objc2-core-graphics" -version = "0.3.2" +name = "pango" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "dispatch2", + "gio", + "glib", "libc", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-io-surface", - "objc2-metal", + "once_cell", + "pango-sys", ] [[package]] -name = "objc2-core-image" -version = "0.3.2" +name = "pango-sys" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", ] [[package]] -name = "objc2-core-location" -version = "0.3.2" +name = "papaya" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", + "equivalent", + "seize", ] [[package]] -name = "objc2-core-media" -version = "0.3.2" +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" -dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "dispatch2", - "objc2 0.6.4", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-video", -] +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] -name = "objc2-core-text" -version = "0.3.2" +name = "parking_lot" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", + "lock_api", + "parking_lot_core", ] [[package]] -name = "objc2-core-video" -version = "0.3.2" +name = "parking_lot_core" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", - "objc2-metal", + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", ] [[package]] -name = "objc2-encode" -version = "4.1.0" +name = "parquet" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +checksum = "5dafa7d01085b62a47dd0c1829550a0a36710ea9c4fe358a05a85477cec8a908" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "base64 0.22.1", + "bytes", + "chrono", + "half", + "hashbrown 0.17.1", + "num-bigint", + "num-integer", + "num-traits", + "paste", + "seq-macro", + "snap", + "thrift", + "twox-hash", + "zstd", +] [[package]] -name = "objc2-exception-helper" +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] -name = "objc2-foundation" -version = "0.2.2" +name = "pathdiff" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.11.1", - "block2 0.5.1", - "libc", - "objc2 0.5.2", -] +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] -name = "objc2-foundation" -version = "0.3.2" +name = "pbkdf2" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "libc", - "objc2 0.6.4", - "objc2-core-foundation", + "digest 0.10.7", + "hmac", ] [[package]] -name = "objc2-image-io" -version = "0.3.2" +name = "pem-rfc7468" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" dependencies = [ - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", + "base64ct", ] [[package]] -name = "objc2-io-bluetooth" -version = "0.3.2" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558b52e7b77f6a68e1e2cc700ff2266779ede47bacb260a33d363b9a623cc761" -dependencies = [ - "libc", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "objc2-io-kit" -version = "0.3.2" +name = "pest" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ - "libc", - "objc2-core-foundation", + "memchr", + "ucd-trie", ] [[package]] -name = "objc2-io-surface" -version = "0.3.2" +name = "pest_derive" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", - "objc2-core-foundation", + "pest", + "pest_generator", ] [[package]] -name = "objc2-media-toolbox" -version = "0.3.2" +name = "pest_generator" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ - "objc2 0.6.4", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-media", + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "objc2-metal" -version = "0.3.2" +name = "pest_meta" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "dispatch2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", + "pest", + "sha2 0.10.9", ] [[package]] -name = "objc2-osa-kit" -version = "0.3.2" +name = "petgraph" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", - "objc2-app-kit", - "objc2-foundation 0.3.2", + "fixedbitset 0.5.7", + "indexmap 2.14.0", ] [[package]] -name = "objc2-quartz-core" -version = "0.3.2" +name = "pharos" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", + "futures", + "rustc_version", ] [[package]] -name = "objc2-security" -version = "0.3.2" +name = "phf" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "bitflags 2.11.1", - "objc2 0.6.4", - "objc2-core-foundation", + "phf_macros 0.11.3", + "phf_shared 0.11.3", ] [[package]] -name = "objc2-system-configuration" -version = "0.3.2" +name = "phf" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "bitflags 2.11.1", - "dispatch2", - "libc", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-security", + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", ] [[package]] -name = "objc2-ui-kit" -version = "0.3.2" +name = "phf_codegen" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", - "objc2-core-text", - "objc2-foundation 0.3.2", - "objc2-quartz-core", - "objc2-user-notifications", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] -name = "objc2-user-notifications" -version = "0.3.2" +name = "phf_codegen" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", + "phf_generator 0.13.1", + "phf_shared 0.13.1", ] [[package]] -name = "objc2-web-kit" -version = "0.3.2" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "bitflags 2.11.1", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation 0.3.2", + "phf_shared 0.11.3", + "rand 0.8.6", ] [[package]] -name = "object" -version = "0.37.3" +name = "phf_generator" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "memchr", + "fastrand", + "phf_shared 0.13.1", ] [[package]] -name = "ocrs" -version = "0.12.2" +name = "phf_macros" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5379fdd3f11522b5a2ff53017a189463dabf5d0a9c915cb3eb97fabec4ea11c" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "anyhow", - "rayon", - "rten", - "rten-imageproc", - "rten-tensor", - "thiserror 2.0.18", - "wasm-bindgen", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "phf_macros" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "critical-section", - "portable-atomic", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "onig" -version = "6.5.1" +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "bitflags 2.11.1", - "libc", - "once_cell", - "onig_sys", + "siphasher", ] [[package]] -name = "onig_sys" -version = "69.9.1" +name = "phf_shared" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "cc", - "pkg-config", + "siphasher", ] [[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "open" -version = "5.3.4" +name = "pin-project" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ - "dunce", - "is-wsl", - "libc", - "pathdiff", + "pin-project-internal", ] [[package]] -name = "openbci" -version = "0.0.1" +name = "pin-project-internal" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4bb76c1335251e20501eae68868b0e8d8f71ee54f9ff1fd420a0ebdd2c2090f" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ - "btleplug 0.11.8", - "futures", - "log", - "serde", - "serde_json", - "serialport", - "thiserror 1.0.69", - "tokio", - "ureq 2.12.1", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "openblas-build" -version = "0.10.15" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd235aa8876fa5c4be452efde09b9b8bafa19aea0bf14a4926508213082439a3" -dependencies = [ - "anyhow", - "cc", - "flate2", - "tar", - "thiserror 2.0.18", - "ureq 3.3.0", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "openblas-src" -version = "0.10.15" +name = "piper" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fccd2c4f5271ab871f2069cb6f1a13ef2c0db50e1145ce03428ee541f4c63c4f" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ - "dirs", - "openblas-build", - "pkg-config", - "vcpkg", + "atomic-waker", + "fastrand", + "futures-io", ] [[package]] -name = "openssl" -version = "0.10.78" +name = "pipewire" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types 0.3.2", + "anyhow", + "bitflags 2.13.0", "libc", + "libspa", + "libspa-sys", + "nix 0.30.1", "once_cell", - "openssl-macros", - "openssl-sys", + "pipewire-sys", + "thiserror 2.0.18", ] [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "pipewire-sys" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "bindgen", + "libspa-sys", + "system-deps 7.0.8", ] [[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.114" +name = "pkcs8" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "der", + "spki", ] [[package]] -name = "option-ext" -version = "0.2.0" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "ordered-float" -version = "2.10.1" +name = "plist" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ - "num-traits", + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.39.4", + "serde", + "time", ] [[package]] -name = "ordered-float" -version = "4.6.0" +name = "plotters" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" dependencies = [ "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "ordered-stream" -version = "0.2.0" +name = "plotters-backend" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" [[package]] -name = "ort" -version = "2.0.0-rc.11" +name = "plotters-svg" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" dependencies = [ - "ndarray 0.17.2", - "ort-sys", - "smallvec", - "tracing", - "ureq 3.3.0", + "plotters-backend", ] [[package]] -name = "ort-sys" -version = "2.0.0-rc.11" +name = "png" +version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ - "hmac-sha256", - "lzma-rust2", - "pkg-config", - "ureq 3.3.0", + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", ] [[package]] -name = "osakit" -version = "0.3.1" +name = "png" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", - "objc2-osa-kit", - "serde", - "serde_json", - "thiserror 2.0.18", + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", ] [[package]] -name = "osf-rs" -version = "0.0.1" +name = "polling" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1030c3973eab027950e90c3dbb618cacf5051e9ac82d47c2d923611cbadd88ea" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "anyhow", - "burn", - "burn-ndarray", - "clap", - "half", - "safetensors 0.7.0", - "serde", - "serde_json", + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] -name = "oura-api" -version = "0.1.2" +name = "pollster" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7b1465d5e8f24dc7d5681665af3793d1d83d764c525353bc8afa10339989af" -dependencies = [ - "paste", - "reqwest 0.11.27", - "serde", - "typed-builder", -] +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" [[package]] -name = "outref" -version = "0.5.2" +name = "pollster" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] -name = "page_size" -version = "0.6.0" +name = "polyval" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ - "libc", - "winapi", + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", ] [[package]] -name = "pango" -version = "0.18.3" +name = "portable-atomic" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", + "serde", ] [[package]] -name = "pango-sys" -version = "0.18.0" +name = "portable-atomic-util" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps 6.2.2", + "portable-atomic", ] [[package]] -name = "papaya" -version = "0.2.4" +name = "portmapper" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +checksum = "64959cbabf952c8ffcbaea13745308508f1f825922f4068353f3de08d42cf214" dependencies = [ - "equivalent", - "seize", + "base64 0.22.1", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.1", + "serde", + "smallvec", + "socket2 0.6.4", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", ] [[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" +name = "postcard" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" dependencies = [ - "lock_api", - "parking_lot_core", + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "postcard-derive", + "serde", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "postcard-derive" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link 0.2.1", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "parquet" -version = "58.1.0" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d3f9f2205199603564127932b89695f52b62322f541d0fc7179d57c2e1c9877" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "ahash", - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-ipc", - "arrow-schema", - "arrow-select", - "base64 0.22.1", - "bytes", - "chrono", - "half", - "hashbrown 0.16.1", - "num-bigint", - "num-integer", - "num-traits", - "paste", - "seq-macro", - "snap", - "thrift", - "twox-hash", - "zstd", + "zerovec", ] [[package]] -name = "paste" -version = "1.0.15" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "pastey" -version = "0.1.1" +name = "ppmd-rust" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" [[package]] -name = "pathdiff" -version = "0.2.3" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] -name = "pbkdf2" -version = "0.12.2" +name = "precomputed-hash" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac", -] +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] -name = "pem-rfc7468" -version = "1.0.0" +name = "prefix-trie" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" dependencies = [ - "base64ct", + "either", + "ipnet", + "num-traits", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "presser" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" [[package]] -name = "pest" -version = "2.8.6" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "memchr", - "ucd-trie", + "proc-macro2", + "syn 2.0.117", ] [[package]] -name = "pest_derive" -version = "2.8.6" +name = "prettytable-rs" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" dependencies = [ - "pest", - "pest_generator", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width 0.1.14", ] [[package]] -name = "pest_generator" -version = "2.8.6" +name = "primal-check" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", + "num-integer", ] [[package]] -name = "pest_meta" -version = "2.8.6" +name = "proc-macro-crate" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" dependencies = [ - "pest", - "sha2 0.10.9", + "once_cell", + "toml_edit 0.19.15", ] [[package]] -name = "petgraph" -version = "0.6.5" +name = "proc-macro-crate" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" dependencies = [ - "fixedbitset", - "indexmap 2.14.0", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", ] [[package]] -name = "pharos" -version = "0.5.3" +name = "proc-macro-crate" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "futures", - "rustc_version", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] -name = "phf" -version = "0.8.0" +name = "proc-macro-error" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ - "phf_shared 0.8.0", + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", ] [[package]] -name = "phf" -version = "0.10.1" +name = "proc-macro-error-attr" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", + "proc-macro2", + "quote", + "version_check", ] [[package]] -name = "phf" -version = "0.11.3" +name = "proc-macro2" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", + "unicode-ident", ] [[package]] -name = "phf" -version = "0.13.1" +name = "profiling" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", - "serde", + "profiling-procmacros", ] [[package]] -name = "phf_codegen" -version = "0.8.0" +name = "profiling-procmacros" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "quote", + "syn 2.0.117", ] [[package]] -name = "phf_codegen" -version = "0.11.3" +name = "proptest" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.0", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", ] [[package]] -name = "phf_codegen" -version = "0.13.1" +name = "prost" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "bytes", + "prost-derive", ] [[package]] -name = "phf_generator" -version = "0.8.0" +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "phf_shared 0.8.0", - "rand 0.8.6", + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", ] [[package]] -name = "phf_generator" -version = "0.10.0" +name = "prost-derive" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.6", + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "phf_generator" -version = "0.11.3" +name = "prost-reflect" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", + "logos", + "miette", + "once_cell", + "prost", + "prost-types", ] [[package]] -name = "phf_generator" -version = "0.13.1" +name = "prost-types" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "fastrand", - "phf_shared 0.13.1", + "prost", ] [[package]] -name = "phf_macros" -version = "0.10.0" +name = "protobuf" +version = "1.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "e14ccd6b79ec748412d4f2dfde1a80fa363a67def4062969f8aed3d790a30f28" [[package]] -name = "phf_macros" -version = "0.11.3" +name = "protoc" +version = "1.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "36db2c00e4519f5c2066c6e01bb73f176de120e4d31e1209dea8583c927faa3d" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "log", ] [[package]] -name = "phf_macros" -version = "0.13.1" +name = "protoc-rust" +version = "1.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "2349054a25cab820bf488beac2b00fd42474db3ed7bad434b421894129d9672a" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.117", + "protobuf", + "protoc", + "tempdir", ] [[package]] -name = "phf_shared" -version = "0.8.0" +name = "protox" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" dependencies = [ - "siphasher 0.3.11", + "bytes", + "miette", + "prost", + "prost-reflect", + "prost-types", + "protox-parse", + "thiserror 1.0.69", ] [[package]] -name = "phf_shared" -version = "0.10.0" +name = "protox-parse" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" dependencies = [ - "siphasher 0.3.11", + "logos", + "miette", + "prost-types", + "thiserror 1.0.69", ] [[package]] -name = "phf_shared" -version = "0.11.3" +name = "psm" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" dependencies = [ - "siphasher 1.0.2", + "ar_archive_writer", + "cc", ] [[package]] -name = "phf_shared" -version = "0.13.1" +name = "pulp" +version = "0.18.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "a0a01a0dc67cf4558d279f0c25b0962bd08fc6dec0137699eae304103e882fe6" dependencies = [ - "siphasher 1.0.2", + "bytemuck", + "libm", + "num-complex", + "reborrow", ] [[package]] -name = "pin-project" -version = "1.1.11" +name = "pulp" +version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" dependencies = [ - "pin-project-internal", + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "reborrow", + "version_check", ] [[package]] -name = "pin-project-internal" -version = "1.1.11" +name = "pulp" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "pulp-wasm-simd-flag" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" [[package]] -name = "piper" -version = "0.2.5" +name = "pxfm" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] -name = "pipewire" -version = "0.9.2" +name = "py_literal" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" +checksum = "102df7a3d46db9d3891f178dcc826dc270a6746277a9ae6436f8d29fd490a8e1" dependencies = [ - "anyhow", - "bitflags 2.11.1", - "libc", - "libspa", - "libspa-sys", - "nix 0.30.1", - "once_cell", - "pipewire-sys", - "thiserror 2.0.18", + "num-bigint", + "num-complex", + "num-traits", + "pest", + "pest_derive", ] [[package]] -name = "pipewire-sys" -version = "0.9.2" +name = "qoi" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" dependencies = [ - "bindgen 0.72.1", - "libspa-sys", - "system-deps 7.0.8", + "bytemuck", ] [[package]] -name = "pkarr" -version = "5.0.4" +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "qrcodegen-image" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bfb9143bbba379f246211eb68074d78db9cc048e4c5701f3b0e6cb1ec67ca2" +checksum = "99530e45ded4640c0eab5420fc60f9a0ec1be51a22e49cc8578b9a0d8be70712" dependencies = [ - "base32", - "bytes", - "cfg_aliases", - "document-features", - "ed25519-dalek", - "getrandom 0.4.2", - "ntimestamp", - "self_cell", - "serde", - "simple-dns", - "thiserror 2.0.18", + "base64 0.22.1", + "image", + "qrcodegen", ] [[package]] -name = "pkcs8" -version = "0.11.0-rc.11" +name = "quanta" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" dependencies = [ - "der", - "spki", + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", ] [[package]] -name = "pkg-config" -version = "0.3.33" +name = "quick-error" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] -name = "plain" -version = "0.2.3" +name = "quick-error" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] -name = "plist" -version = "1.8.0" +name = "quick-xml" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", - "quick-xml 0.38.4", - "serde", - "time", + "memchr", ] [[package]] -name = "plotters" -version = "0.3.7" +name = "quick-xml" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", + "memchr", ] [[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" +name = "quick-xml" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ - "plotters-backend", + "memchr", ] [[package]] -name = "png" -version = "0.17.16" +name = "quote" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", + "proc-macro2", ] [[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.11.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", -] +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "pollster" -version = "0.4.0" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "polyval" -version = "0.6.2" +name = "rand" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", ] [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "rand" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "serde", + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", ] [[package]] -name = "portable-atomic-util" -version = "0.2.7" +name = "rand" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "portable-atomic", + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] -name = "portmapper" -version = "0.15.0" +name = "rand" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74748bc706fa6b6aebac6bbe0bbe0de806b384cb5c557ea974f771360a4e3858" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "base64 0.22.1", - "bytes", - "derive_more 2.1.1", - "futures-lite", - "futures-util", - "hyper-util", - "igd-next", - "iroh-metrics", - "libc", - "n0-error", - "netwatch", - "num_enum", - "rand 0.9.4", - "serde", - "smallvec", - "socket2 0.6.3", - "time", - "tokio", - "tokio-util", - "tower-layer", - "tracing", - "url", + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] -name = "postcard" -version = "1.1.3" +name = "rand_chacha" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "heapless", - "postcard-derive", - "serde", + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] -name = "postcard-derive" -version = "0.2.2" +name = "rand_chacha" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "rand_core" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" dependencies = [ - "zerovec", + "rand_core 0.4.2", ] [[package]] -name = "powerfmt" -version = "0.2.0" +name = "rand_core" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" [[package]] -name = "ppmd-rust" -version = "1.4.0" +name = "rand_core" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "zerocopy", + "getrandom 0.3.4", ] [[package]] -name = "precomputed-hash" -version = "0.1.1" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "presser" -version = "0.3.1" +name = "rand_distr" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.6", +] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "rand_distr" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ - "proc-macro2", - "syn 2.0.117", + "num-traits", + "rand 0.9.4", ] [[package]] -name = "prettytable" -version = "0.10.0" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46480520d1b77c9a3482d39939fcf96831537a250ec62d4fd8fbdf8e0302e781" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "csv", - "encode_unicode", - "is-terminal", - "lazy_static", - "term", - "unicode-width 0.1.14", + "rand_core 0.10.1", ] [[package]] -name = "primal-check" -version = "0.3.4" +name = "rand_xorshift" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "num-integer", + "rand_core 0.9.5", ] [[package]] -name = "proc-macro-crate" -version = "1.3.1" +name = "range-alloc" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" [[package]] -name = "proc-macro-crate" -version = "2.0.2" +name = "ratatui" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", + "serde", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "ratatui-core" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "bitflags 2.13.0", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", ] [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "ratatui-crossterm" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", + "cfg-if", + "crossterm", + "instability", + "ratatui-core", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "ratatui-macros" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" dependencies = [ - "proc-macro2", - "quote", - "version_check", + "ratatui-core", + "ratatui-widgets", ] [[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" +name = "ratatui-termwiz" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" +checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" +dependencies = [ + "ratatui-core", + "termwiz", +] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "ratatui-widgets" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" dependencies = [ - "unicode-ident", + "bitflags 2.13.0", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", ] [[package]] -name = "profiling" -version = "1.0.17" +name = "rav1e" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" dependencies = [ - "profiling-procmacros", + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", ] [[package]] -name = "profiling-procmacros" -version = "1.0.17" +name = "ravif" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" dependencies = [ - "quote", - "syn 2.0.117", + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rayon", + "rgb", ] [[package]] -name = "proptest" -version = "1.11.0" +name = "raw-cpuid" +version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", - "bitflags 2.11.1", - "num-traits", - "rand 0.9.4", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", + "bitflags 2.13.0", ] [[package]] -name = "prost" -version = "0.13.5" +name = "raw-window-handle" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "bytes", - "prost-derive", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core", ] [[package]] -name = "prost-derive" -version = "0.13.5" +name = "rawpointer" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ - "anyhow", + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", "itertools 0.14.0", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] -name = "psm" -version = "0.1.31" +name = "referencing" +version = "0.46.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "69e4e17ef386c5383591d07623d3de49cbc601156e7582973e6db98d66a57de2" dependencies = [ - "ar_archive_writer", - "cc", + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", ] [[package]] -name = "pulp" -version = "0.21.5" +name = "regex" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ - "bytemuck", - "cfg-if", - "libm", - "num-complex", - "reborrow", - "version_check", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] -name = "pulp" -version = "0.22.2" +name = "regex-automata" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ - "bytemuck", + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.14", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls", + "hyper-tls 0.6.0", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", "cfg-if", - "libm", - "num-complex", - "paste", - "pulp-wasm-simd-flag", - "raw-cpuid", - "reborrow", - "version_check", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rlsl" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "285ef6a8dbee750b4cee85378693f3c71d92408ad8669a9939adfdb0d51b0056" +dependencies = [ + "crossbeam-channel", + "fxhash", + "hostname", + "log", + "once_cell", + "parking_lot", + "socket2 0.5.10", + "tokio", + "uuid", +] + +[[package]] +name = "rlsl" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82be1852a0603b035d754ba6013581afea291c3dd944dac74f391ce66a12d5da" +dependencies = [ + "crossbeam-channel", + "fxhash", + "hostname", + "log", + "once_cell", + "parking_lot", + "socket2 0.5.10", + "tokio", + "uuid", +] + +[[package]] +name = "rlsl-iroh" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7663957cfd3ef08e719d34d7e7cd6856e43a1436e1a4576e549562cbd48bf11a" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "clap", + "env_logger", + "iroh", + "log", + "lz4_flex", + "rlsl 0.0.5", + "serde", + "serde_json", + "snap", + "tokio", + "zstd", ] [[package]] -name = "pulp-wasm-simd-flag" -version = "0.1.0" +name = "rlx" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" +checksum = "f26b6279de02c01f975cec81389e0a13001884da69a82714559936c6384a92df" +dependencies = [ + "anyhow", + "rlx-driver", + "rlx-gguf", + "rlx-ir", + "rlx-macros", + "rlx-opt", + "rlx-runtime", +] [[package]] -name = "pxfm" -version = "0.1.29" +name = "rlx-autodiff" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "7e0b880a1d0dfa5405a871179b5045fe2a3a760042ffcac7d897ef447eb9afb7" +dependencies = [ + "half", + "rlx-fusion", + "rlx-ir", +] [[package]] -name = "qoi" -version = "0.4.1" +name = "rlx-bert" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +checksum = "e1998a57e43b0e8f50033d3587f339486a5487dce396827fc27587a3a570eb31" dependencies = [ - "bytemuck", + "anyhow", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", + "serde", ] [[package]] -name = "qrcodegen" -version = "1.8.0" +name = "rlx-bonsai" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" +checksum = "f0cbcec5ef80a312ff5aa9dfb795ccf37f052333b24622eae1b619a5aed21e6b" +dependencies = [ + "anyhow", + "rlx-cli", + "rlx-llama-base", + "rlx-llama32", + "rlx-runtime", +] [[package]] -name = "qrcodegen-image" -version = "1.5.1" +name = "rlx-cli" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99530e45ded4640c0eab5420fc60f9a0ec1be51a22e49cc8578b9a0d8be70712" +checksum = "d8d84c98f4695c531765776f749f928b12703daeadd0dd6d694116729dcd6250" dependencies = [ - "base64 0.22.1", - "image", - "qrcodegen", + "anyhow", + "clap", + "phf 0.11.3", + "rlx-gguf", + "rlx-models-core", + "rlx-runtime", + "rlx-text", + "serde_json", ] [[package]] -name = "quick-error" -version = "1.2.3" +name = "rlx-clinicalbert" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +checksum = "ebab8a0478963a01dd924d0d08d8a3998cfbe9231ac64a936677ad4bdb335c91" +dependencies = [ + "anyhow", + "rlx-bert", + "rlx-cpu", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", +] [[package]] -name = "quick-error" -version = "2.0.1" +name = "rlx-cohere" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +checksum = "b7c1df7f31d0591bd787c61b657f21a37d843a04b63a37411b84248b59d96519" +dependencies = [ + "anyhow", + "rlx-cli", + "rlx-llama-base", + "rlx-llama32", +] [[package]] -name = "quick-xml" -version = "0.30.0" +name = "rlx-compile" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +checksum = "2eab1700e30164853a5c5d35255d33a48320140239f250f37fa742f8506138ef" dependencies = [ - "memchr", + "rlx-autodiff", + "rlx-fusion", + "rlx-ir", ] [[package]] -name = "quick-xml" -version = "0.37.5" +name = "rlx-cpu" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "d36e0887a25fcceda2b5c7e62fd8fae68b29b7041c72ca9e1a58145ce258c45b" dependencies = [ - "memchr", + "half", + "rayon", + "rlx-gguf", + "rlx-ir", + "rlx-opt", ] [[package]] -name = "quick-xml" -version = "0.38.4" +name = "rlx-cuda" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "31384bf817d989f5f343117c519e2023373dc4561bd61ee6c7d1f167cf9dbd3e" dependencies = [ - "memchr", + "bytemuck", + "cudarc", + "rlx-cpu", + "rlx-gpu-kernels", + "rlx-ir", + "rlx-opt", + "serde", + "serde_json", ] [[package]] -name = "quick-xml" -version = "0.39.2" +name = "rlx-diamond" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b548a69f824b21ee2bb2a0d82b2b8875bdaaf0eecafa371238e9d4043a458b" + +[[package]] +name = "rlx-dinov2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "d71446b1420e1480d58b014b35c0170a381aef4027794f99118636ebaf5e0120" dependencies = [ - "memchr", + "anyhow", + "rlx-cli", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "quinn" -version = "0.11.9" +name = "rlx-driver" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "f9f431f27e622b31e7503a9b08974af07809d75c50affcede2182fcb8d4ce8e8" dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.2", - "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", + "half", + "rlx-ir", ] [[package]] -name = "quinn-proto" -version = "0.11.14" +name = "rlx-embed" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "9c63a8b8e2ff04154375ec57f2081e88c0187a14e9875df6f4ef175813ccdfa2" dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash 2.1.2", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", + "anyhow", + "rlx-bert", + "rlx-cpu", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-models-core", + "rlx-nomic", + "rlx-opt", + "rlx-runtime", + "rlx-vision", + "safetensors 0.7.0", + "serde", + "serde_json", + "tokenizers", ] [[package]] -name = "quinn-udp" -version = "0.5.14" +name = "rlx-fft" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "5c6eb8aa8687f04b6da0f52951b4804c1747a682c3ff45942d2913cdcb42477e" dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.3", - "tracing", - "windows-sys 0.60.2", + "anyhow", + "rand 0.8.6", + "rlx-autodiff", + "rlx-cli", + "rlx-compile", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "rustfft", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "quote" -version = "1.0.45" +name = "rlx-flow" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "645cd633587635edd19b3da6866635a3bdcf9d73c3e6148717eb1e9c247bfccf" dependencies = [ - "proc-macro2", + "anyhow", + "rlx-ir", + "serde", + "toml 0.8.2", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "rlx-flux2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "15397042681090f85417ca88969fcf037ac8641b4ea47dd9b7d4a08214c52eac" +dependencies = [ + "anyhow", + "bytemuck", + "half", + "image", + "rlx-cli", + "rlx-cpu", + "rlx-diamond", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-qwen3", + "rlx-runtime", + "rlx-tensor", + "safetensors 0.7.0", + "serde", + "serde_json", + "tokenizers", +] [[package]] -name = "r-efi" -version = "6.0.0" +name = "rlx-fusion" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "96f891882f925f19ef4cd080109139949bd56314b3467c04bb6e47c74aa68942" +dependencies = [ + "rlx-ir", +] [[package]] -name = "rand" -version = "0.8.6" +name = "rlx-gemma" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "9f4266ce757f0e210317ca19e7bf4b17bcffdff442c892165b3158309463d4ce" dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", + "anyhow", + "image", + "rlx-cli", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-qwen3", + "rlx-qwen35", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", + "tokenizers", ] [[package]] -name = "rand" -version = "0.9.4" +name = "rlx-gguf" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "5b98e5c14ebd4e72417441490a7bac721bba1ef4c403836aa1354b1dd367db2c" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "anyhow", + "bytemuck", + "half", ] [[package]] -name = "rand" -version = "0.10.1" +name = "rlx-gpu-kernels" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "9f7647ef802b6082fa666520ca484bd2d5936f37d5978d02dac92a21aad06ef2" + +[[package]] +name = "rlx-granite" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a2f93e33ad33a548a9c5b8e2fc86a0cd92a08d3847c4890519a780e84058b8" dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", + "anyhow", + "rlx-cli", + "rlx-llama-base", + "rlx-llama32", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "rlx-ir" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "f154b383c698f79a23f8a665beebb9319060e43bbb762ad16eed3f70ae50b8da" dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "serde", + "serde_json", + "smallvec", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rlx-kittentts" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "9442e51ede420fc18061f15bf42a268896c2b6f27fe1f77fba25113cbfc87446" dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "anyhow", + "hound", + "kitten_tts_mini_rlx", + "once_cell", + "ort", + "regex", + "rlx", + "rlx-cli", + "rlx-runtime", + "serde", + "serde_json", + "zip 2.4.2", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "rlx-lfm" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "2a5058a4a2bd7a8ad26cb64b9768918880f3418b369f82b14b3be9d481d43f08" dependencies = [ - "getrandom 0.2.17", + "anyhow", + "rlx-cli", + "rlx-cpu", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-llama-base", + "rlx-models-core", + "rlx-runtime", + "rlx-ssm", + "serde", + "serde_json", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "rlx-llada2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "26bb157c4ed524edd859106f8af771cb0310515f6c0fa694cf27d9c4638b15f0" dependencies = [ - "getrandom 0.3.4", + "anyhow", + "bytemuck", + "rlx-cpu", + "rlx-flow", + "rlx-ir", + "rlx-metal", + "rlx-mlx", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "rand_core" -version = "0.10.1" +name = "rlx-llama-base" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "c3454a7fa00a9e86555159ece9657447b564d53c6f84aac84ae121cead16c5fc" +dependencies = [ + "anyhow", + "rlx-gguf", + "serde", +] [[package]] -name = "rand_distr" -version = "0.5.1" +name = "rlx-llama32" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +checksum = "b7e4c9cb7f07ae94af0c33d470694cf1c960c5a027b8fc650d8b152f9ead01af" dependencies = [ - "num-traits", - "rand 0.9.4", + "anyhow", + "rlx-cli", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-qwen3", + "rlx-qwen35", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", + "tokenizers", ] [[package]] -name = "rand_xorshift" -version = "0.4.0" +name = "rlx-locateanything" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "a02ae1100cc534f74c6eb6f84fa3cccfe3c55e6193b1d9688669b9c27cedb187" dependencies = [ - "rand_core 0.9.5", + "anyhow", + "hf-hub 0.5.0", + "image", + "rlx-cli", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-qwen3", + "rlx-runtime", + "serde", + "serde_json", + "tokenizers", ] [[package]] -name = "range-alloc" -version = "0.1.5" +name = "rlx-macros" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +checksum = "af64d367f20076030628db137363ea48a1d5561557ffc5a37557184ead4190d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "ratatui" -version = "0.30.0" +name = "rlx-metal" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "122972c0a352a9e910d937d4b5d1f75fe3ff85619eb00a110dab540445a85dca" dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", - "ratatui-widgets", + "bytemuck", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "half", + "metal 0.30.0", + "objc", + "rlx-cpu", + "rlx-ir", + "rlx-opt", + "serde", + "serde_json", ] [[package]] -name = "ratatui-core" -version = "0.1.0" +name = "rlx-minicpm5" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "2611d7e913835209e6e5edf6cccbae4912c0a526a92d4d478460d73b7c2cdd98" dependencies = [ - "bitflags 2.11.1", - "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools 0.14.0", - "kasuari", - "lru", - "strum 0.27.2", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.2", + "anyhow", + "rlx-cli", + "rlx-llama-base", + "rlx-llama32", + "rlx-runtime", + "serde", + "serde_json", ] [[package]] -name = "ratatui-crossterm" -version = "0.1.0" +name = "rlx-minimax" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "4f2684181d21f7e30bb9c15eb8aa1e551d3364e605263a4a3549de8ae0ef0f06" dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", + "anyhow", + "rlx-cli", + "rlx-cpu", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-llama-base", + "rlx-models-core", + "rlx-runtime", + "rlx-ssm", + "serde", + "serde_json", ] [[package]] -name = "ratatui-macros" -version = "0.7.0" +name = "rlx-mistral" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "548b277a5342c9829a44b010cea9d8c17fa06d412200e419d662cda98f91022c" dependencies = [ - "ratatui-core", - "ratatui-widgets", + "anyhow", + "rlx-cli", + "rlx-llama-base", + "rlx-llama32", ] [[package]] -name = "ratatui-termwiz" -version = "0.1.0" +name = "rlx-mlx" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "74a50c604aa94ff3f2dbf495bef5c74ed61504f5638fe9058c581c67722ad27f" dependencies = [ - "ratatui-core", - "termwiz", + "half", + "rlx-cpu", + "rlx-gguf", + "rlx-ir", + "rlx-mlx-sys", + "rlx-opt", + "serde", + "serde_json", ] [[package]] -name = "ratatui-widgets" -version = "0.3.0" +name = "rlx-mlx-sys" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "cc1f20816f784800a48db835fcf2299daa099905311acec1c4a46cf648e3ccce" dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools 0.14.0", - "line-clipping", - "ratatui-core", - "strum 0.27.2", - "time", - "unicode-segmentation", - "unicode-width 0.2.2", + "cc", + "cmake", ] [[package]] -name = "rav1e" -version = "0.8.1" +name = "rlx-models" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +checksum = "8c629d56422cc6c87b12c932c61a73586fedd60e61ab841d6d344e1140f2f88c" dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.4", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", + "anyhow", + "bytemuck", + "half", + "hound", + "rlx-bert", + "rlx-bonsai", + "rlx-cli", + "rlx-clinicalbert", + "rlx-cohere", + "rlx-cpu", + "rlx-diamond", + "rlx-dinov2", + "rlx-embed", + "rlx-flow", + "rlx-flux2", + "rlx-gemma", + "rlx-gguf", + "rlx-granite", + "rlx-ir", + "rlx-kittentts", + "rlx-lfm", + "rlx-llada2", + "rlx-llama-base", + "rlx-llama32", + "rlx-locateanything", + "rlx-metal", + "rlx-minicpm5", + "rlx-mistral", + "rlx-mlx", + "rlx-models-core", + "rlx-neutts", + "rlx-nomic", + "rlx-ocr", + "rlx-omnicoder", + "rlx-opt", + "rlx-phi", + "rlx-qwen3", + "rlx-qwen3-tts", + "rlx-qwen35", + "rlx-runtime", + "rlx-sam", + "rlx-sam-ir", + "rlx-sam2", + "rlx-sam3", + "rlx-tensor", + "rlx-vad", + "rlx-vision", + "rlx-vjepa2", + "rlx-voxtral", + "rlx-voxtral-tts", + "rlx-wav2vec2-bert", + "rlx-whisper", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "ravif" -version = "0.13.0" +name = "rlx-models-core" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +checksum = "80e2c902b1e1b695ee5422824f751129f03c432a09530ff744a57366c87fb065" dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error 2.0.1", - "rav1e", + "anyhow", + "bytemuck", + "half", + "memmap2", "rayon", - "rgb", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "raw-cpuid" -version = "11.6.0" +name = "rlx-nemotron" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +checksum = "f94b19829df2c07b6cb8f96902711e0348a1e11c2b5b2c108ce90d01667c9ff4" dependencies = [ - "bitflags 2.11.1", + "anyhow", + "rlx-cli", + "rlx-cpu", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-llama-base", + "rlx-llama32", + "rlx-models-core", + "rlx-runtime", + "rlx-ssm", + "serde", + "serde_json", ] [[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "rawpointer" -version = "0.2.1" +name = "rlx-neutts" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" +checksum = "94e64dbda357481fd7a6fa6aca9ebb97b565068b2fb944905b334dae45fe7bd2" +dependencies = [ + "anyhow", + "encoding_rs", + "memmap2", + "ndarray 0.16.1", + "once_cell", + "rand 0.8.6", + "regex", + "rlx-llama-base", + "rlx-llama32", + "rlx-models-core", + "rlx-qwen3", + "rlx-qwen35", + "rlx-runtime", + "rustfft", + "safetensors 0.7.0", + "thiserror 1.0.69", +] [[package]] -name = "rayon" -version = "1.12.0" +name = "rlx-nomic" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +checksum = "ed4e2db609081f4ac496625f36969b930aa8b71994df4d1733704a7eceaceb0a" dependencies = [ - "either", - "rayon-core", + "anyhow", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", + "serde", ] [[package]] -name = "rayon-cond" -version = "0.4.0" +name = "rlx-ocr" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +checksum = "43cd30cb718bac8007e26bc9943cf9b855c334a6465687ac2a28b22e74d35871" dependencies = [ - "either", - "itertools 0.14.0", - "rayon", + "anyhow", + "bytemuck", + "half", + "image", + "memmap2", + "rlx-cli", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-runtime", + "rten", + "rten-imageproc", + "rten-tensor", + "safetensors 0.7.0", ] [[package]] -name = "rayon-core" -version = "1.13.0" +name = "rlx-omnicoder" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +checksum = "9f9a8dbf645fd24d258b02c1fa2df25d307520c91112b1c8469c9a44884ef659" dependencies = [ - "crossbeam-deque", - "crossbeam-utils", + "anyhow", + "rlx-cli", + "rlx-llama-base", + "rlx-qwen3", ] [[package]] -name = "reborrow" -version = "0.5.5" +name = "rlx-onnx-import" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" +checksum = "7abb8c787aad02a31829ebb99e630380e5fc1839880d0e926b1c6be6531a20e2" +dependencies = [ + "anyhow", + "bytemuck", + "half", + "onnx", + "protobuf", + "rlx-ir", + "safetensors 0.7.0", + "serde", + "serde_json", +] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "rlx-opt" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "8299c7855db8364bd044b5ef6eae6c0ee5760c39d19bed532ef9bba9ca15c0b4" dependencies = [ - "bitflags 2.11.1", + "rlx-autodiff", + "rlx-compile", + "rlx-fusion", + "rlx-ir", ] [[package]] -name = "redox_syscall" -version = "0.7.4" +name = "rlx-phi" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "90d92f3226c577ba6bd363660c277e4626e2c8d49094e308416295102dcff7f0" dependencies = [ - "bitflags 2.11.1", + "anyhow", + "rlx-cli", + "rlx-llama-base", + "rlx-llama32", ] [[package]] -name = "redox_users" -version = "0.4.6" +name = "rlx-qwen3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "8ff4e8d2021db1e045f25b7ba34bf1e8232c12ceb32e3e1c506d74468921200d" dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", + "anyhow", + "rlx-cli", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "redox_users" -version = "0.5.2" +name = "rlx-qwen3-tts" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "d0aaa66e5010a8bf0bd2ade628c0235c9a853876f91957330a213e4eedd48ae1" dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", + "anyhow", + "memmap2", + "ndarray 0.16.1", + "rand 0.8.6", + "rayon", + "rlx-cli", + "rlx-cpu", + "rlx-fft", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-qwen3", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", + "tokenizers", ] -[[package]] -name = "ref-cast" -version = "1.0.25" +[[package]] +name = "rlx-qwen35" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "df342547fe1a31fa3bd6472e3718e4bd09da83bce23463f771e6ce76776751b4" dependencies = [ - "ref-cast-impl", + "anyhow", + "rlx-cli", + "rlx-cpu", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-llada2", + "rlx-models-core", + "rlx-opt", + "rlx-qwen3", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", + "tokenizers", ] [[package]] -name = "ref-cast-impl" -version = "1.0.25" +name = "rlx-rocm" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "e9d894970e171abd46ca9dcb3d344647e2763fc97979ea398525869ba3da6a1a" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "libloading 0.8.9", + "rlx-cpu", + "rlx-gpu-kernels", + "rlx-ir", + "rlx-opt", + "serde", + "serde_json", ] [[package]] -name = "referencing" -version = "0.46.2" +name = "rlx-runtime" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb0c66c7b78c1da928bee668b5cc638c678642ff587faff6e6222f797be9d4c" +checksum = "cd6b9bf3dfb732fb9b80d3a80b2cc563cb6d70ff0b5068e956e7f1f736b2fda7" dependencies = [ - "ahash", - "fluent-uri", - "getrandom 0.3.4", - "hashbrown 0.16.1", - "itoa", - "micromap", - "parking_lot", - "percent-encoding", + "anyhow", + "half", + "inventory", + "rlx-cpu", + "rlx-cuda", + "rlx-driver", + "rlx-ir", + "rlx-macros", + "rlx-metal", + "rlx-mlx", + "rlx-opt", + "rlx-rocm", + "rlx-wgpu", + "serde", "serde_json", ] [[package]] -name = "regex" -version = "1.12.3" +name = "rlx-sam" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "4c5ccb6c35cc2b79dd7fa5786c827d08604169dee7cfb6a5df70b45b778f9342" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "anyhow", + "rlx-cli", + "rlx-cpu", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "rlx-sam-ir", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "regex-automata" -version = "0.4.14" +name = "rlx-sam-ir" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "000c506e05f0b7b7cafeb04a4326469057bc83c14975ee2575c7d99142a0ad07" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "anyhow", + "rlx-cpu", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", ] [[package]] -name = "regex-syntax" -version = "0.8.10" +name = "rlx-sam2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "49647ba892a1daa53b960cec2a84b599bc69aa19a38b2d0bf23ab846a7f361cb" +dependencies = [ + "anyhow", + "rlx-cli", + "rlx-cpu", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "rlx-sam", + "rlx-sam-ir", + "safetensors 0.7.0", + "serde", + "serde_json", +] [[package]] -name = "relative-path" -version = "1.9.3" +name = "rlx-sam3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +checksum = "115c379f2e0111866255f0cea87cadb8a7f24831b343ee57ce6458fd2f8630de" +dependencies = [ + "anyhow", + "rlx-cli", + "rlx-cpu", + "rlx-flow", + "rlx-gguf", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "rlx-sam", + "rlx-tensor", + "safetensors 0.7.0", + "serde", + "serde_json", +] [[package]] -name = "renderdoc-sys" -version = "1.1.0" +name = "rlx-ssm" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +checksum = "fa4bfa4cf86ef9db10f15880c1088c27cbcee8b01e72138eaedea74307fcba0a" +dependencies = [ + "anyhow", + "rlx-cpu", + "rlx-flow", + "rlx-ir", +] [[package]] -name = "reqwest" -version = "0.11.27" +name = "rlx-tensor" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +checksum = "61fa209662bedd2b69a4ea069b42e6b5eb28f9aba2d3ca38e612f7eec56577e4" dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-tls 0.5.0", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration 0.5.1", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg 0.50.0", + "anyhow", + "rlx-cpu", ] [[package]] -name = "reqwest" -version = "0.12.28" +name = "rlx-text" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "e5b84d154ffe8e13f5359f2c79b47763e5812348110cead67a5bd6d6d0c194a4" dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.4.13", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.9.0", - "hyper-rustls", - "hyper-tls 0.6.0", - "hyper-util", - "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", + "anyhow", + "minijinja", + "rlx-gguf", + "rlx-runtime", "serde", "serde_json", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "tokio", - "tokio-native-tls", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", - "webpki-roots 1.0.7", + "tokenizers", ] [[package]] -name = "reqwest" -version = "0.13.2" +name = "rlx-umap" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "be6e63e4d32630e2dd8e91d821f921b55a4565198fc1a90032c0974486fdf5b5" dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.9.0", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", + "crossbeam-channel", + "ctrlc", + "rand 0.9.4", + "rayon", + "rlx-autodiff", + "rlx-compile", + "rlx-cpu", + "rlx-driver", + "rlx-gguf", + "rlx-ir", + "rlx-metal", + "rlx-mlx", + "rlx-runtime", + "safetensors 0.7.0", "serde", "serde_json", - "sync_wrapper 1.0.2", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.5.0", - "web-sys", ] [[package]] -name = "resolv-conf" -version = "0.7.6" +name = "rlx-vad" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +checksum = "0ec06923c6fb946e842eb5466703cddd20feea7032b1cf68d283241bf6fc61b7" +dependencies = [ + "anyhow", + "rlx-cli", + "rlx-cpu", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-runtime", +] [[package]] -name = "reve-rs" -version = "0.0.1" +name = "rlx-vision" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "174b48f52088459b8c609203822e20d0c937b96c1a557cbc955f5ece822c4606" +checksum = "e0b81149fb15d841dbfbcbaf18f735e13efa69be92ee396c61665ad49cc7721d" dependencies = [ "anyhow", - "burn", - "burn-ndarray", - "clap", - "half", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", "safetensors 0.7.0", "serde", - "serde_json", ] [[package]] -name = "rfd" -version = "0.17.2" +name = "rlx-vjepa2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +checksum = "eb9d159615009e9c42ad9af713a7fa229896bb54c5037ccffd5ba9ae175f9fd2" dependencies = [ - "block2 0.6.2", - "dispatch2", - "js-sys", - "libc", - "log", - "objc2 0.6.4", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "percent-encoding", - "pollster", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.61.2", + "anyhow", + "rlx-cli", + "rlx-cpu", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "rlx-tensor", + "safetensors 0.7.0", + "serde", + "serde_json", ] [[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - -[[package]] -name = "ring" -version = "0.17.14" +name = "rlx-voxtral" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "d4dd17804c31b9f1965247115a614e84be3d17997616f9bb2e18c32765e85bad" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "anyhow", + "rlx-cli", + "rlx-flow", + "rlx-ir", + "rlx-llama32", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "rlx-whisper", + "safetensors 0.7.0", + "serde", + "serde_json", + "tokenizers", ] [[package]] -name = "rlsl" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "285ef6a8dbee750b4cee85378693f3c71d92408ad8669a9939adfdb0d51b0056" -dependencies = [ - "crossbeam-channel", - "fxhash", - "hostname", - "log", - "once_cell", - "parking_lot", - "socket2 0.5.10", - "tokio", - "uuid", +name = "rlx-voxtral-tts" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74cd3facb1a79434cdd86c2ce5f9a8f08eaf06a1df5d615605644747f2b56325" +dependencies = [ + "anyhow", + "bytemuck", + "half", + "hound", + "kitoken", + "memmap2", + "ndarray 0.16.1", + "rand 0.8.6", + "rand_distr 0.4.3", + "rlx-cli", + "rlx-flow", + "rlx-ir", + "rlx-llama32", + "rlx-models-core", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", + "zip 2.4.2", ] [[package]] -name = "rlsl-iroh" -version = "0.0.4" +name = "rlx-wav2vec2-bert" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5aa3167d3ce741e503553e8522fdc0c9e231059941e6bb91c333ee1a31947c0" +checksum = "7a481f8d05211aec89f5f8be7e0189d5b4e7a7d1e6af819e43eba45ab5b67c64" dependencies = [ "anyhow", - "base64 0.22.1", - "bytes", - "clap", - "env_logger", - "iroh", - "log", - "lz4_flex", - "rlsl", + "rlx-cli", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", "serde", "serde_json", - "snap", - "tokio", - "zstd", ] [[package]] -name = "rmp" -version = "0.8.15" +name = "rlx-wgpu" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +checksum = "5a890e1900c77959bec88dba70b7014ffbf61787a257c511577b3fc87cb4faa3" dependencies = [ - "num-traits", + "bytemuck", + "half", + "pollster 0.3.0", + "rlx-compile", + "rlx-cpu", + "rlx-ir", + "rlx-opt", + "serde", + "serde_json", + "wgpu", ] [[package]] -name = "rmp-serde" -version = "1.3.1" +name = "rlx-whisper" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +checksum = "17172e46bee97662432c5d0d11a5dfbc2ad343f90ffe81a314686cfe688cad22" dependencies = [ - "rmp", + "anyhow", + "rlx-cli", + "rlx-flow", + "rlx-ir", + "rlx-models-core", + "rlx-opt", + "rlx-runtime", + "safetensors 0.7.0", "serde", + "serde_json", + "tokenizers", ] [[package]] @@ -11164,56 +11462,24 @@ dependencies = [ [[package]] name = "rsqlite-vfs" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", "thiserror 2.0.18", ] -[[package]] -name = "rstest" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", -] - -[[package]] -name = "rstest_macros" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.117", - "unicode-ident", -] - [[package]] name = "rten" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c230fa4ade87c913f61dbd911b7eb0d49460ceff3f1e4fabc837fac191137c" dependencies = [ - "flatbuffers 24.12.23", "num_cpus", "rayon", "rten-base", "rten-gemm", - "rten-model-file", - "rten-onnx", "rten-shape-inference", "rten-simd", "rten-tensor", @@ -11254,22 +11520,6 @@ dependencies = [ "rten-tensor", ] -[[package]] -name = "rten-model-file" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2f8d270f07ab1bbfff47250c6039f6caa5da59d6da7d74f66aa48559aa6fea" -dependencies = [ - "flatbuffers 24.12.23", - "rten-base", -] - -[[package]] -name = "rten-onnx" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23086eef75bfb55278cb0b45cf9f5a877d466d914914aafebee4ffca9b24d20c" - [[package]] name = "rten-shape-inference" version = "0.24.0" @@ -11308,6 +11558,18 @@ dependencies = [ "rten-simd", ] +[[package]] +name = "rubato" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "realfft", +] + [[package]] name = "rusb" version = "0.9.4" @@ -11324,7 +11586,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -11380,7 +11642,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -11393,7 +11655,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -11402,9 +11664,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -11417,9 +11679,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -11438,9 +11700,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -11448,13 +11710,13 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.21.1", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -11508,6 +11770,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "safetensors" version = "0.4.5" @@ -11548,15 +11819,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "sanitize-filename" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" -dependencies = [ - "regex", -] - [[package]] name = "schannel" version = "0.1.29" @@ -11654,7 +11916,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -11667,7 +11929,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -11694,49 +11956,25 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - [[package]] name = "selectors" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.1", - "cssparser 0.36.0", - "derive_more 2.1.1", + "bitflags 2.13.0", + "cssparser", + "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash 2.1.2", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] -[[package]] -name = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - [[package]] name = "semver" version = "1.0.28" @@ -11753,6 +11991,18 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sentencepiece-model" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec" +dependencies = [ + "miette", + "prost", + "prost-build", + "protox", +] + [[package]] name = "seq-macro" version = "0.3.6" @@ -11836,9 +12086,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -11901,11 +12151,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -11920,9 +12171,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -11943,6 +12194,26 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yml" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909764a65f86829ccdb5eea9ab355843aa02c019a7bfd47465092953565caa05" +dependencies = [ + "noyalib", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -11971,7 +12242,7 @@ version = "4.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "core-foundation 0.10.1", "core-foundation-sys", @@ -11984,16 +12255,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "servo_arc" version = "0.4.3" @@ -12014,6 +12275,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -12027,13 +12294,13 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0-rc.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.11.0-rc.10", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -12051,6 +12318,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -12084,9 +12357,22 @@ dependencies = [ [[package]] name = "signature" -version = "3.0.0-rc.10" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simba" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] [[package]] name = "simd-adler32" @@ -12094,6 +12380,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -12111,28 +12407,22 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "simple-dns" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df350943049174c4ae8ced56c604e28270258faec12a6a48637a7655287c9ce0" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skill" -version = "0.0.129" +version = "0.0.131-rc.6" dependencies = [ "anyhow", "base64 0.22.1", @@ -12148,7 +12438,6 @@ dependencies = [ "llmfit-core", "log", "macos-focus", - "neutts", "objc2 0.6.4", "objc2-app-kit", "objc2-foundation 0.3.2", @@ -12176,6 +12465,7 @@ dependencies = [ "skill-label-index", "skill-llm", "skill-location", + "skill-neutts", "skill-oura", "skill-settings", "skill-skills", @@ -12184,7 +12474,7 @@ dependencies = [ "skill-tts", "skill-vision", "stacker", - "sysinfo 0.38.4", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-global-shortcut", @@ -12249,9 +12539,6 @@ dependencies = [ "brainmaster", "brainvision", "btleplug 0.11.8", - "burn", - "burn-mlx", - "burn-ndarray", "chrono", "cognionics", "dirs", @@ -12260,7 +12547,6 @@ dependencies = [ "futures", "glob", "gtec", - "half", "hex", "hf-hub 0.5.0", "libc", @@ -12273,11 +12559,12 @@ dependencies = [ "neurosky", "notify", "notify-rust", - "osf-rs", + "objc2 0.6.4", + "objc2-app-kit", "rand 0.10.1", "regex", - "reve-rs", - "rlsl", + "rlsl 0.0.4", + "rlx", "rusqlite", "serde", "serde_json", @@ -12307,10 +12594,7 @@ dependencies = [ "skill-skills", "skill-tools", "skill-tts", - "sleepfm", - "sleeplm", - "steegformer", - "sysinfo 0.38.4", + "sysinfo", "tempfile", "thiserror 2.0.18", "tokio", @@ -12353,7 +12637,10 @@ dependencies = [ "dirs", "fastembed", "hex", + "hf-hub 0.5.0", "rand 0.10.1", + "rlx", + "rlx-models", "serde", "serde_json", "sha2 0.10.9", @@ -12369,6 +12656,7 @@ dependencies = [ "skill-settings", "tempfile", "thiserror 2.0.18", + "tokenizers", "tokio", "tracing", ] @@ -12395,7 +12683,7 @@ dependencies = [ "skill-gpu", "skill-health", "skill-oura", - "sysinfo 0.38.4", + "sysinfo", "tempfile", "thiserror 2.0.18", ] @@ -12407,7 +12695,7 @@ dependencies = [ "antneuro", "async-trait", "awear", - "bitflags 2.11.1", + "bitflags 2.13.0", "cognionics", "emotiv", "hermes-ble", @@ -12431,8 +12719,8 @@ name = "skill-eeg" version = "0.0.1" dependencies = [ "criterion", - "gpu-fft", "proptest", + "rlx", "rustfft", "serde", "serde_json", @@ -12444,17 +12732,16 @@ name = "skill-exg" version = "0.0.1" dependencies = [ "anyhow", - "burn", - "burn-ndarray", - "burn-wgpu", - "cubecl-runtime", + "eegdino", "hf-hub 0.5.0", "log", - "neurorvq", + "neurorvq-rs", + "rlx", "serde", "serde_json", "skill-constants", "skill-data", + "skill-devices", "skill-eeg", "ureq 3.3.0", ] @@ -12467,7 +12754,7 @@ dependencies = [ "llmfit-core", "serde", "serde_json", - "sysinfo 0.38.4", + "sysinfo", ] [[package]] @@ -12478,12 +12765,12 @@ dependencies = [ "base64 0.22.1", "crossbeam-channel", "gtk", - "http 1.4.0", + "http 1.4.2", "serde", "serde_json", - "tao 0.35.0", + "tao 0.34.8", "thiserror 2.0.18", - "wry 0.55.0", + "wry 0.54.4", ] [[package]] @@ -12548,6 +12835,7 @@ dependencies = [ name = "skill-label-index" version = "0.0.1" dependencies = [ + "accelerate-src", "fast-hnsw", "rusqlite", "serde", @@ -12555,6 +12843,7 @@ dependencies = [ "skill-constants", "skill-data", "tempfile", + "turbovec", ] [[package]] @@ -12568,8 +12857,14 @@ dependencies = [ "either", "glob", "hf-hub 0.5.0", - "llama-cpp-4", + "image", "log", + "rlx", + "rlx-gemma", + "rlx-minicpm5", + "rlx-minimax", + "rlx-models", + "rlx-nemotron", "rusqlite", "serde", "serde_json", @@ -12605,7 +12900,7 @@ dependencies = [ "iroh", "log", "rand 0.10.1", - "rlsl", + "rlsl 0.0.4", "rlsl-iroh", "serde", "serde_json", @@ -12615,6 +12910,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "skill-neutts" +version = "0.0.1" +dependencies = [ + "anyhow", + "dirs", + "espeak-ng", + "fancy-regex 0.14.0", + "hf-hub 0.5.0", + "hound", + "once_cell", + "rlx-neutts", + "safetensors 0.5.3", + "sha2 0.10.9", + "zip 7.2.0", +] + [[package]] name = "skill-oura" version = "0.0.1" @@ -12633,13 +12945,9 @@ name = "skill-router" version = "0.0.1" dependencies = [ "anyhow", - "burn", - "burn-cubecl", - "burn-mlx", - "crossbeam-channel", - "cubecl", - "fast-umap", - "half", + "hotpath", + "rlx-runtime", + "rlx-umap", "rusqlite", "serde", "serde_json", @@ -12660,12 +12968,13 @@ dependencies = [ "fast-hnsw", "fastembed", "gif", + "hf-hub 0.5.0", "image", "objc2 0.6.4", "objc2-app-kit", - "ocrs", "ort", - "rten", + "rlx", + "rlx-models", "serde", "serde_json", "skill-constants", @@ -12690,6 +12999,8 @@ dependencies = [ "skill-eeg", "skill-llm", "skill-tts", + "tempfile", + "tracing", ] [[package]] @@ -12746,86 +13057,47 @@ dependencies = [ "hf-hub 0.5.0", "hound", "kittentts", - "neutts", "rodio", "serde", "serde_json", "sha2 0.10.9", "skill-constants", + "skill-neutts", "tokio", ] [[package]] -name = "skill-vision" +name = "skill-tty" version = "0.1.0" -dependencies = [ - "cc", - "image", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "sleepfm" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e50d6a671cc1fe4bb7927ab9dd7b183b6964632a8354fc22edeab42a9e29305d" -dependencies = [ - "anyhow", - "burn", - "burn-ndarray", - "clap", - "half", - "safetensors 0.7.0", - "serde", - "serde_json", -] - -[[package]] -name = "sleeplm" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced74400919ed0688623a456896f46904c6bcdf037b36dff28a4e9556fbdb2cd" dependencies = [ "anyhow", - "burn", - "burn-ndarray", - "bytemuck", - "clap", - "half", - "safetensors 0.7.0", - "serde", - "serde_json", + "chrono", + "dirs", + "libc", ] [[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +name = "skill-vision" +version = "0.1.0" dependencies = [ - "version_check", + "cc", + "image", ] [[package]] -name = "slug" -version = "0.1.6" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" -dependencies = [ - "deunicode", - "wasm-bindgen", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "snap" @@ -12845,9 +13117,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -12879,7 +13151,7 @@ dependencies = [ "objc2-foundation 0.3.2", "objc2-quartz-core", "raw-window-handle", - "redox_syscall 0.5.18", + "redox_syscall", "tracing", "wasm-bindgen", "web-sys", @@ -12929,32 +13201,19 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - [[package]] name = "spin" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" -dependencies = [ - "lock_api", - "portable-atomic", -] [[package]] name = "spirv" -version = "0.3.0+sdk-1.3.268.0" +version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -12981,9 +13240,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.3" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", @@ -12991,12 +13250,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "stable-vec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dac7bc0f7d0d44329b200020effbc25a534d89fa142af95e3ddf76113412a5e" - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -13023,22 +13276,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "steegformer" -version = "0.1.0" +name = "statrs" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8268a0dbf4fadae3d5b313c4386270bca4978248096705ce5a9dd6a5d41d98cf" +checksum = "f697a07e4606a0a25c044de247e583a330dbb1731d11bc7350b81f48ad567255" dependencies = [ - "anyhow", - "burn", - "burn-ndarray", - "bytemuck", - "clap", - "half", - "libm", - "rayon", - "safetensors 0.7.0", - "serde", - "serde_json", + "approx", + "nalgebra", + "num-traits", + "rand 0.8.6", ] [[package]] @@ -13047,19 +13293,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - [[package]] name = "string_cache" version = "0.9.0" @@ -13072,18 +13305,6 @@ dependencies = [ "precomputed-hash", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" @@ -13111,15 +13332,6 @@ dependencies = [ "strum_macros 0.26.4", ] -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - [[package]] name = "strum" version = "0.28.0" @@ -13142,18 +13354,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "strum_macros" version = "0.28.0" @@ -13183,12 +13383,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - [[package]] name = "symphonia" version = "0.5.5" @@ -13292,7 +13486,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "enum-as-inner", "libc", @@ -13300,20 +13494,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "sysinfo" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.61.3", -] - [[package]] name = "sysinfo" version = "0.38.4" @@ -13345,7 +13525,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -13389,7 +13569,7 @@ version = "7.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" dependencies = [ - "cfg-expr 0.20.7", + "cfg-expr 0.20.8", "heck 0.5.0", "pkg-config", "toml 1.1.2+spec-1.1.0", @@ -13408,16 +13588,15 @@ version = "0.34.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", - "dlopen2 0.8.2", + "dlopen2", "dpi", "gdkwayland-sys", - "gdkx11-sys", "gtk", "jni 0.21.1", "libc", @@ -13437,24 +13616,25 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "windows-version", - "x11-dl", ] [[package]] name = "tao" -version = "0.35.0" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", + "dbus", "dispatch2", - "dlopen2 0.8.2", + "dlopen2", "dpi", "gdkwayland-sys", + "gdkx11-sys", "gtk", "jni 0.21.1", "libc", @@ -13475,6 +13655,7 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "windows-version", + "x11-dl", ] [[package]] @@ -13490,15 +13671,21 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", "xattr", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "target-lexicon" version = "0.12.16" @@ -13507,15 +13694,15 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" dependencies = [ "anyhow", "bytes", @@ -13527,7 +13714,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http 1.4.0", + "http 1.4.2", "image", "jni 0.21.1", "libc", @@ -13542,7 +13729,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.2", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -13565,9 +13752,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.6" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" dependencies = [ "anyhow", "cargo_toml", @@ -13581,15 +13768,14 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" dependencies = [ "base64 0.22.1", "brotli", @@ -13614,9 +13800,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -13628,9 +13814,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.5.4" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" dependencies = [ "anyhow", "glob", @@ -13639,15 +13825,14 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-plugin-global-shortcut" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" dependencies = [ "global-hotkey", "log", @@ -13679,9 +13864,9 @@ dependencies = [ [[package]] name = "tauri-plugin-opener" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", @@ -13696,7 +13881,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows 0.61.3", - "zbus 5.14.0", + "zbus 5.16.0", ] [[package]] @@ -13711,9 +13896,9 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" dependencies = [ "serde", "serde_json", @@ -13721,7 +13906,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus 5.14.0", + "zbus 5.16.0", ] [[package]] @@ -13734,13 +13919,13 @@ dependencies = [ "dirs", "flate2", "futures-util", - "http 1.4.0", + "http 1.4.2", "infer", "log", "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.13.2", + "reqwest 0.13.4", "rustls", "semver", "serde", @@ -13759,14 +13944,14 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" dependencies = [ "cookie", "dpi", "gtk", - "http 1.4.0", + "http 1.4.2", "jni 0.21.1", "objc2 0.6.4", "objc2-ui-kit", @@ -13784,12 +13969,12 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", - "http 1.4.0", + "http 1.4.2", "jni 0.21.1", "log", "objc2 0.6.4", @@ -13798,36 +13983,36 @@ dependencies = [ "percent-encoding", "raw-window-handle", "softbuffer", - "tao 0.34.8", + "tao 0.35.3", "tauri-runtime", "tauri-utils", "url", "webkit2gtk", "webview2-com", "windows 0.61.3", - "wry 0.54.4", + "wry 0.55.1", ] [[package]] name = "tauri-utils" -version = "2.8.3" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", - "html5ever 0.29.1", - "http 1.4.0", + "http 1.4.2", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf 0.13.1", + "plist", "proc-macro2", "quote", "regex", @@ -13839,7 +14024,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", "urlpattern", "uuid", @@ -13848,13 +14033,13 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -13869,6 +14054,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "tempdir" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" +dependencies = [ + "rand 0.4.6", + "remove_dir_all", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -13882,17 +14077,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "tendril" version = "0.5.0" @@ -13952,11 +14136,11 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", - "fixedbitset", + "fixedbitset 0.4.2", "hex", "lazy_static", "libc", @@ -13971,7 +14155,7 @@ dependencies = [ "phf 0.11.3", "sha2 0.10.9", "signal-hook", - "siphasher 1.0.2", + "siphasher", "terminfo", "termios", "thiserror 1.0.69", @@ -13986,23 +14170,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "text_placeholder" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5008f74a09742486ef0047596cf35df2b914e2a8dca5727fcb6ba6842a766b" -dependencies = [ - "hashbrown 0.13.2", - "serde", - "serde_json", -] - -[[package]] -name = "textdistance" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa672c55ab69f787dbc9126cc387dbe57fdd595f585e4524cf89018fa44ab819" - [[package]] name = "thiserror" version = "1.0.69" @@ -14129,6 +14296,18 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -14199,9 +14378,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -14209,7 +14388,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -14327,20 +14506,21 @@ dependencies = [ [[package]] name = "tokio-websockets" -version = "0.12.3" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ "base64 0.22.1", "bytes", "futures-core", "futures-sink", - "getrandom 0.3.4", - "http 1.4.0", + "getrandom 0.4.2", + "http 1.4.2", "httparse", - "rand 0.9.4", + "rand 0.10.1", "ring", "rustls-pki-types", + "sha1_smol", "simdutf8", "tokio", "tokio-rustls", @@ -14386,7 +14566,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -14442,14 +14622,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -14458,7 +14638,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -14494,150 +14674,50 @@ dependencies = [ "futures-util", "pin-project-lite", "sync_wrapper 1.0.2", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.11.1", - "bytes", - "futures-core", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "http-range-header", - "httpdate", - "iri-string", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "tokio", - "tokio-util", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracel-ash" -version = "0.38.0+1.3.296" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7318626319ea7b43d20b9c374f273b1b25600b8d8ddd103e209751f67fee67ad" -dependencies = [ - "ash", - "c2rust-bitfields", -] - -[[package]] -name = "tracel-llvm" -version = "20.1.4-7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "982535db9eb1a30ac0f2c50239a0eec3e5cf50993a88e92b04747bd2f4d365b2" -dependencies = [ - "tracel-mlir-rs", - "tracel-mlir-sys", -] - -[[package]] -name = "tracel-llvm-bundler" -version = "20.1.4-7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c75b8e477cb8d49d907afab029ca74d48459f5b88c27bdb4c6cd6acb5e61977" -dependencies = [ - "anyhow", - "bytes", - "constcat", - "dirs", - "liblzma", - "regex", - "reqwest 0.12.28", - "serde", - "serde_json", - "sha2 0.10.9", - "tar", - "walkdir", -] - -[[package]] -name = "tracel-mlir-rs" -version = "20.1.4-7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a478a35efd68d0ba73f747adfb7923b121c64e7f5be9cd8364ca1dcb772d5c" -dependencies = [ - "tracel-mlir-rs-macros", - "tracel-mlir-sys", -] - -[[package]] -name = "tracel-mlir-rs-macros" -version = "20.1.4-7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a94f36868c3b10b1825945223d99d106c73f4d249f063caa4651deeb9379344" -dependencies = [ - "comrak", - "convert_case 0.8.0", - "proc-macro2", - "quote", - "regex", - "syn 2.0.117", - "tracel-llvm-bundler", - "tracel-tblgen-rs", - "unindent", + "tokio", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "tracel-mlir-sys" -version = "20.1.4-7" +name = "tower-http" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02f26d31af0c225a6d2e3d65d012fd6de848c9fc776897b152ee83b7d1bd15c4" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "tracel-llvm-bundler", + "bitflags 2.13.0", + "bytes", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", ] [[package]] -name = "tracel-rspirv" -version = "0.12.1+sdk-1.4.341.0" +name = "tower-layer" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1653aa21b867351f48c51f1063a2f872f8e82931951cae469d8a53aa4d7d72e8" -dependencies = [ - "bitflags 2.11.1", - "rustc-hash 2.1.2", -] +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] -name = "tracel-tblgen-rs" -version = "20.1.4-7" +name = "tower-service" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d2581070380418ccc33b500f3739e4d4869421fdb477fcea51ff97c6253a52" -dependencies = [ - "bindgen 0.71.1", - "cc", - "paste", - "thiserror 2.0.18", - "tracel-llvm-bundler", -] +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -14651,19 +14731,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-appender" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" -dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - [[package]] name = "tracing-attributes" version = "0.1.31" @@ -14726,9 +14793,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", "dirs", @@ -14740,33 +14807,70 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "tribev2" -version = "0.0.4" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b1eee4f03c9961e4bbae9d08f0a55ea35ff67e9dae6da005f7636058d8a5688" +checksum = "f8787f1c923262aee6f4a8f012467b759c282b3ad5f1196a6dda61faa00e083c" dependencies = [ "anyhow", - "burn", - "burn-ndarray", "bytemuck", "cc", "clap", "flate2", "half", - "llama-cpp-4", + "rlx", + "rlx-flow", + "rlx-llama32", + "rlx-models-core", "safetensors 0.7.0", "serde", "serde_json", "serde_yaml", "tempfile", "tracing", + "tribev2-audio", + "tribev2-video", +] + +[[package]] +name = "tribev2-audio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1554989d1923d4a75689aae1edb38395fbf651ca959b69ff67d64a40674d885" +dependencies = [ + "anyhow", + "hound", + "rlx", + "rlx-flow", + "rlx-models-core", + "rlx-wav2vec2-bert", + "rubato", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "tribev2-video" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7adc19ef4d8c101e2f94652a3028421a020a57ac2b02f2e0c36ecb8756c04d58" +dependencies = [ + "anyhow", + "image", + "rlx", + "rlx-models-core", + "rlx-vjepa2", + "serde", + "serde_json", + "tempfile", ] [[package]] @@ -14784,7 +14888,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "native-tls", @@ -14802,7 +14906,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "rand 0.9.4", @@ -14821,7 +14925,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "rand 0.9.4", @@ -14838,7 +14942,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "rand 0.9.4", @@ -14847,25 +14951,28 @@ dependencies = [ ] [[package]] -name = "twox-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" - -[[package]] -name = "type-map" -version = "0.5.1" +name = "turbovec" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +checksum = "0185030ca15ea4f8b00c22f02021baa613c2ed3fc83449e32c368debd4a4fe37" dependencies = [ - "rustc-hash 2.1.2", + "blas-src", + "faer", + "ndarray 0.17.2", + "openblas-src", + "ordered-float 4.6.0", + "rand 0.8.6", + "rand_chacha 0.3.1", + "rand_distr 0.4.3", + "rayon", + "statrs", ] [[package]] -name = "typed-arena" -version = "2.0.2" +name = "twox-hash" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" [[package]] name = "typed-builder" @@ -14901,9 +15008,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -15051,9 +15158,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -15090,12 +15197,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" -[[package]] -name = "unindent" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" - [[package]] name = "unit-prefix" version = "0.5.2" @@ -15124,12 +15225,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "ureq" version = "2.12.1" @@ -15181,7 +15276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64 0.22.1", - "http 1.4.0", + "http 1.4.2", "httparse", "log", ] @@ -15243,9 +15338,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "atomic", "getrandom 0.4.2", @@ -15281,17 +15376,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "variadics_please" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "vcpkg" version = "0.2.15" @@ -15307,32 +15391,21 @@ dependencies = [ "anyhow", "derive_builder", "rustversion", - "vergen-lib 9.1.0", + "vergen-lib", ] [[package]] name = "vergen-gitcl" -version = "1.0.8" +version = "9.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" dependencies = [ "anyhow", "derive_builder", "rustversion", "time", "vergen", - "vergen-lib 0.1.6", -] - -[[package]] -name = "vergen-lib" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", + "vergen-lib", ] [[package]] @@ -15358,12 +15431,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "void" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" - [[package]] name = "vsimd" version = "0.8.0" @@ -15453,9 +15520,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -15466,9 +15533,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" dependencies = [ "js-sys", "wasm-bindgen", @@ -15476,9 +15543,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -15486,9 +15553,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -15499,9 +15566,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -15560,7 +15627,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -15586,7 +15653,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -15598,7 +15665,7 @@ version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -15610,7 +15677,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -15624,7 +15691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml 0.39.2", + "quick-xml 0.39.4", "quote", ] @@ -15634,7 +15701,7 @@ version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc1846eb04c49182e04f4a099e2a830a2b745610bbc1d61246e206f29c7000a0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "downcast-rs", "rustix 1.1.4", "wayland-backend", @@ -15656,9 +15723,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" dependencies = [ "js-sys", "wasm-bindgen", @@ -15682,8 +15749,8 @@ checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "string_cache", + "string_cache_codegen", ] [[package]] @@ -15767,8 +15834,8 @@ dependencies = [ "webview2-com-sys", "windows 0.61.3", "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", ] [[package]] @@ -15873,16 +15940,17 @@ dependencies = [ [[package]] name = "wgpu" -version = "26.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70b6ff82bbf6e9206828e1a3178e851f8c20f1c9028e74dd3a8090741ccd5798" +checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250" dependencies = [ "arrayvec", - "bitflags 2.11.1", + "bitflags 2.13.0", + "bytemuck", "cfg-if", "cfg_aliases", "document-features", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "js-sys", "log", "naga", @@ -15902,17 +15970,18 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "26.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f62f1053bd28c2268f42916f31588f81f64796e2ff91b81293515017ca8bd9" +checksum = "02da3ad1b568337f25513b317870960ef87073ea0945502e44b864b67a8c77b7" dependencies = [ "arrayvec", - "bit-set 0.8.0", - "bit-vec 0.8.0", - "bitflags 2.11.1", + "bit-set 0.9.1", + "bit-vec 0.9.1", + "bitflags 2.13.0", + "bytemuck", "cfg_aliases", "document-features", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "indexmap 2.14.0", "log", "naga", @@ -15925,110 +15994,118 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", "wgpu-hal", + "wgpu-naga-bridge", "wgpu-types", ] [[package]] name = "wgpu-core-deps-apple" -version = "26.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18ae5fbde6a4cbebae38358aa73fcd6e0f15c6144b67ef5dc91ded0db125dbdf" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "26.0.0" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7670e390f416006f746b4600fdd9136455e3627f5bd763abf9a65daa216dd2d" +checksum = "62e51b5447e144b3dbba4feb01f80f4fa21696fa0cd99afb2c3df1affd6fdb28" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "26.0.0" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "720a5cb9d12b3d337c15ff0e24d3e97ed11490ff3f7506e7f3d98c68fa5d6f14" +checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "26.0.6" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d0e67224cc7305b3b4eb2cc57ca4c4c3afc665c1d1bee162ea806e19c47bdd" +checksum = "31f8e1a9e7a8512f276f7c62e018c7fa8d60954303fed2e5750114332049193f" dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set 0.8.0", - "bitflags 2.11.1", - "block", + "bit-set 0.9.1", + "bitflags 2.13.0", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", - "core-graphics-types 0.2.0", - "glow", - "glutin_wgl_sys", - "gpu-alloc", "gpu-allocator", "gpu-descriptor", - "hashbrown 0.15.5", - "js-sys", - "khronos-egl", + "hashbrown 0.16.1", "libc", "libloading 0.8.9", "log", - "metal 0.32.0", "naga", - "ndk-sys", - "objc", - "ordered-float 4.6.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float 5.3.0", "parking_lot", "portable-atomic", "portable-atomic-util", "profiling", "range-alloc", "raw-window-handle", + "raw-window-metal", "renderdoc-sys", "smallvec", "thiserror 2.0.18", - "wasm-bindgen", - "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02" +dependencies = [ + "naga", "wgpu-types", - "windows 0.58.0", - "windows-core 0.58.0", ] [[package]] name = "wgpu-types" -version = "26.0.0" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2" +checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytemuck", "js-sys", "log", - "thiserror 2.0.18", + "raw-window-handle", "web-sys", ] [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "widestring" version = "1.2.1" @@ -16081,16 +16158,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" @@ -16134,27 +16201,14 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -16166,8 +16220,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -16195,17 +16249,6 @@ dependencies = [ "windows-threading 0.2.1", ] -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -16217,17 +16260,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -16282,15 +16314,6 @@ dependencies = [ "windows-strings 0.5.1", ] -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -16309,16 +16332,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -16675,15 +16688,12 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -16708,16 +16718,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "winreg" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -16782,7 +16782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -16849,7 +16849,7 @@ dependencies = [ "dunce", "gdkx11", "gtk", - "http 1.4.0", + "http 1.4.2", "javascriptcore-rs", "jni 0.21.1", "libc", @@ -16879,9 +16879,9 @@ dependencies = [ [[package]] name = "wry" -version = "0.55.0" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2 0.6.2", @@ -16893,7 +16893,7 @@ dependencies = [ "dunce", "gdkx11", "gtk", - "http 1.4.0", + "http 1.4.2", "javascriptcore-rs", "jni 0.21.1", "libc", @@ -16990,9 +16990,9 @@ dependencies = [ [[package]] name = "xcap" -version = "0.9.4" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d20dc4057d755e2b78da287f9057065a89c507af69e1a5eac19e868c21cd8a" +checksum = "b6ad471d5ba232bc276382d26a9d3b837d6853b7df389058b5bb1e94dcdd248c" dependencies = [ "dispatch2", "image", @@ -17016,7 +17016,7 @@ dependencies = [ "widestring", "windows 0.62.2", "xcb", - "zbus 5.14.0", + "zbus 5.16.0", ] [[package]] @@ -17048,9 +17048,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml" -version = "1.2.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8aa498d22c9bbaf482329839bc5620c46be275a19a812e9a22a2b07529a642a" +checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" [[package]] name = "xml-rs" @@ -17087,9 +17087,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive 0.8.2", @@ -17120,12 +17120,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "z32" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" - [[package]] name = "zbus" version = "4.4.0" @@ -17160,9 +17154,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.14.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", "async-executor", @@ -17187,10 +17181,10 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.15", - "zbus_macros 5.14.0", - "zbus_names 4.3.1", - "zvariant 5.10.0", + "winnow 1.0.3", + "zbus_macros 5.16.0", + "zbus_names 4.3.2", + "zvariant 5.12.0", ] [[package]] @@ -17208,17 +17202,17 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", - "zbus_names 4.3.1", - "zvariant 5.10.0", - "zvariant_utils 3.3.0", + "zbus_names 4.3.2", + "zvariant 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -17234,29 +17228,29 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 0.7.15", - "zvariant 5.10.0", + "winnow 1.0.3", + "zvariant 5.12.0", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -17265,9 +17259,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -17311,7 +17305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", ] @@ -17321,7 +17315,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "yoke 0.8.2", + "yoke 0.8.3", "zerofrom", "zerovec-derive", ] @@ -17448,19 +17442,17 @@ dependencies = [ [[package]] name = "zuna-rs" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2474f0f061170c59b4bedc67b157ad3a473acacb5e3fd117a925c178a09e60" +checksum = "4daa31d5dcaadd23095a6d894caa539febbe40f43296f6d7d375708a4c70073c" dependencies = [ "anyhow", - "burn", - "burn-mlx", - "burn-ndarray", "clap", "exg", "half", "ndarray 0.17.2", "rayon", + "rlx", "safetensors 0.7.0", "serde", "serde_json", @@ -17505,16 +17497,16 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", - "zvariant_derive 5.10.0", - "zvariant_utils 3.3.0", + "winnow 1.0.3", + "zvariant_derive 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -17532,15 +17524,15 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils 3.3.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -17556,13 +17548,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow 0.7.15", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 2a2d7dd1..db561123 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ [workspace] resolver = "2" -exclude = ["patches/muda-0.17.2"] +exclude = [] members = [ "src-tauri", "crates/skill-autostart", @@ -43,7 +43,9 @@ members = [ "crates/skill-skills", "crates/skill-tools", "crates/skill-tray", + "crates/skill-neutts", "crates/skill-tts", + "crates/skill-tty", "crates/skill-vision", "crates/iroh_test_client", "crates/iroh-example-client", @@ -53,12 +55,6 @@ members = [ "crates/skill-daemon", ] -# ── Patch: Apple-Silicon Metal fix for cubek-matmul ─────────────────────────── -# -# cubek-matmul 0.1.1 panics on Apple Silicon when the SimpleCyclicCmma tile -# (40 KB) exceeds Metal's 32 KB shared-memory limit. The patched fork extends -# the 'auto' fallback chain: SimpleCyclicCmma → SimpleUnit → Naive. -# # btleplug: improved macOS BLE scanning behaviour. # NOTE: upstream branch has a typo ("imrpoved" → "improved"); tracked for rename. # @@ -66,10 +62,7 @@ members = [ # from member Cargo.toml files. [patch.crates-io] -cubek-matmul = { git = "https://github.com/eugenehp/cubek.git", branch = "cubek-matmul", package = "cubek-matmul" } btleplug = { git = "https://github.com/eugenehp/btleplug.git", branch = "imrpoved_mac_version" } -# Fix muda ZeroWidth panic in to_png() on macOS (upstream bug in 0.17.2) -muda = { path = "patches/muda-0.17.2" } # Fix glib VariantStrIter unsoundness (GHSA-wrw7-89jp-8q8g) — &p → &mut p # All gtk-rs-core crates must come from the same source to keep -sys types aligned. glib = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch = "0.18-patched" } @@ -78,13 +71,11 @@ gobject-sys = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch gio = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch = "0.18-patched" } gio-sys = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch = "0.18-patched" } glib-macros = { git = "https://github.com/eugenehp/gtk-rs-core.git", branch = "0.18-patched" } -# Fix rand 0.7.3 vulnerability (GHSA-2qph-qpvm-2qf7) pulled in by selectors → phf_codegen → phf_generator -phf_generator = { path = "patches/phf_generator-0.8.0" } -# burn-mlx on crates.io (0.1.2) targets burn 0.16; the git main branch supports -# burn 0.20 which we use. This patch also covers fast-umap's transitive dep. -burn-mlx = { git = "https://github.com/eidola-ai/burn-mlx", branch = "burn-0-20" } +# tribev2: using crates.io 0.1.0 (no patch needed — restore path dep when developing locally) +# neutts: now provided by crates/skill-neutts (workspace subcrate wrapping rlx-neutts 0.2.5) +# No patch needed — local path dep removed to eliminate the links="llama" conflict. # emotiv: using crates.io 0.0.12 (no patch needed — restore path dep when developing locally) -# zuna-rs: using crates.io 0.1.3 (no patch needed — restore path dep when developing locally) +# zuna-rs: using crates.io 0.2.0 (no patch needed — restore path dep when developing locally) @@ -115,6 +106,19 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg # to unpack a .nsis.zip built with standard Deflate (method 8). zip = { version = "7", features = ["deflate"] } +# RLX — published on crates.io (features set per crate). +# rlx — https://github.com/MIT-RLX/rlx — runtime + IR +# rlx-models — https://github.com/MIT-RLX/rlx-models — model loaders / runners +# For local development against sibling checkouts, swap these back to path deps: +# rlx = { path = "../rlx/rlx", default-features = false } +# rlx-models = { path = "../rlx-models/crates/rlx-models", default-features = false } +rlx = { version = "0.2.5", default-features = false } +rlx-models = { version = "0.2.5", default-features = false } +rlx-minicpm5 = { version = "0.2.5", default-features = false } +rlx-minimax = { version = "0.2.5", default-features = false } +rlx-nemotron = { version = "0.2.5", default-features = false } +rlx-gemma = { version = "0.2.5", default-features = false } + # ── Workspace lints ──────────────────────────────────────────────────────────── # # Consistent lint policy across every crate. Individual crates inherit these @@ -185,17 +189,24 @@ debug = 0 # codegen-units = 1 + fat LTO, but compiles significantly faster. # ── Test profile ────────────────────────────────────────────────────────────── # -# llama-cpp-sys-4 wraps the llama.cpp C++ library. Without optimisations, -# CPU inference runs at a fraction of native speed, making the LLM E2E test -# painfully slow (~1-2 tok/s instead of 20+ tok/s). Optimising just this -# -sys crate (and its transitive C++ build) has no measurable impact on -# incremental test compile times since it is already built by cmake. -[profile.test.package.llama-cpp-sys-4] -opt-level = 3 - -[profile.test.package.llama-cpp-4] -opt-level = 3 - [profile.release] lto = "thin" codegen-units = 8 + +# ── Size-optimised release profile (opt-in) ──────────────────────────────── +# Builds a noticeably smaller binary than `release` at the cost of much +# longer link time. Used by the release-min CI builds and the size-comparison +# matrix. Use with `cargo build --profile release-min ...`. +# +# Wins (measured on skill-daemon llm-rlx-metal): +# * lto = "fat" — cross-crate dead-code elimination (~5-10%) +# * codegen-units = 1 — wider monomorphisation merging (~2-5%) +# * strip = "symbols" — drops local + debuginfo + symtab (~3-8%) +# Combined: typically 10-20% smaller than `release`. +# +# Not the default because the link step grows from ~3 min to ~10+ min on M4. +[profile.release-min] +inherits = "release" +lto = "fat" +codegen-units = 1 +strip = "symbols" diff --git a/Dockerfile.upgrade-test b/Dockerfile.upgrade-test new file mode 100644 index 00000000..63025bd4 --- /dev/null +++ b/Dockerfile.upgrade-test @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.7 +# Linux end-to-end test harness for the daemon failsafe upgrade flow. +# Used by scripts/test-upgrade-linux-docker.sh. +# +# Two scopes: +# A — primitives e2e (kill / port / state / hash / atomic copy) against real +# Python subprocesses. Covers daemon_upgrade::*. +# B — orchestrator e2e (full ensure_daemon_runtime_ready state machine) +# against a real skill-daemon build. Covers daemon_cmds::ensure_*. +# +# Both scopes run inside the same image; SCOPE env var selects which. +# Uses BuildKit cache mounts so cargo registry + target cache survive rebuilds. + +# Stable Rust 1.85+ required: workspace transitively depends on cubek-matmul +# which uses the (now-stabilized) edition2024 feature. +FROM rust:1-bookworm + +ENV DEBIAN_FRONTEND=noninteractive \ + CARGO_TERM_COLOR=always \ + RUST_BACKTRACE=1 \ + CARGO_HOME=/usr/local/cargo \ + CARGO_TARGET_DIR=/work/target + +# System deps. The libwebkit2gtk + libsoup + libgtk set is overkill for the +# upgrade tests (no Tauri/webview at runtime), but the `skill` crate's +# dependency graph still pulls them in at *compile* time on Linux. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + pkg-config \ + libssl-dev \ + libdbus-1-dev \ + libudev-dev \ + libasound2-dev \ + libxdo-dev \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libsoup-3.0-dev \ + libjavascriptcoregtk-4.1-dev \ + procps \ + lsof \ + psmisc \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /work +COPY . . + +ENV SCOPE=A + +CMD ["bash", "-c", "/work/scripts/run-upgrade-tests-in-container.sh"] diff --git a/build-support/linux_openblas.rs b/build-support/linux_openblas.rs new file mode 100644 index 00000000..7cb657cc --- /dev/null +++ b/build-support/linux_openblas.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Shared build.rs helper: link Debian/Ubuntu system OpenBLAS on Linux. +// +// `libopenblas-dev` installs `libopenblas.so.0` under update-alternatives +// subdirs (e.g. `openblas-pthread/`) that are not on the default loader path. +// We emit link-search + rpath so `cargo test` and release binaries resolve it +// without `LD_LIBRARY_PATH`. +// +// Lives under `build-support/` (not `build/`) so SvelteKit's `npm run build` +// output to `build/` cannot clobber this file before release CI compiles Rust. + +// Emit `cargo:rustc-link-*` directives when `enabled` and target OS is Linux. +pub fn link_system_openblas(enabled: bool) { + if !enabled || std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux") { + return; + } + + for dir in &[ + "/usr/lib/x86_64-linux-gnu/openblas-pthread", + "/usr/lib/x86_64-linux-gnu/openblas-openmp", + "/usr/lib/x86_64-linux-gnu", + ] { + if std::path::Path::new(dir).exists() { + println!("cargo:rustc-link-search={dir}"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{dir}"); + } + } + println!("cargo:rustc-link-lib=openblas"); +} diff --git a/changes/releases/0.0.130-rc.1.md b/changes/releases/0.0.130-rc.1.md new file mode 100644 index 00000000..f1f4b4ff --- /dev/null +++ b/changes/releases/0.0.130-rc.1.md @@ -0,0 +1,769 @@ +## [0.0.130-rc.1] — 2026-04-29 + +### Features + +- **Human vs AI activity tracking**: every edit and commit is now classified as `source: "human"` or `source: "ai"` in real-time. `AIActivityTracker` watches VSCode commands (Copilot/Codeium completions, inline chat, AI-generated commit messages) to tag subsequent edits/commits, exposes `getAIRatioForFile()` (rolling 5-minute ratio) for CodeLens + sidebar, and forwards the `source` field to the daemon for `build_events` / `ai_events` storage. + +## How it works + +The `AIActivityTracker` monitors VSCode command execution to detect AI tool usage: + +- **Inline completions** (Copilot, Codeium) — edits within 5 seconds after an AI command are tagged `source: "ai"` +- **Inline chat** (`inlineChat.start`, `copilot.interactiveEditor.*`) — subsequent edits in the same file are tagged AI +- **Commit messages** — `github.copilot.git.generateCommitMessage` marks the next commit as AI-assisted +- **Everything else** — classified as `source: "human"` + +## What's tracked + +| Signal | Classification | +|--------|---------------| +| Manual typing | `human` | +| Copilot inline suggestion accepted | `ai` | +| Copilot inline chat edits | `ai` | +| Paste from external source | `human` | +| AI-generated commit message | `ai` | +| Manually typed commit message | `human` | + +## Per-file AI ratio + +`AIActivityTracker.getAIRatioForFile(path)` returns a rolling 5-minute ratio (0.0 = all human, 1.0 = all AI) used by: +- CodeLens annotations (shows "AI-Assisted" vs focus score) +- Sidebar (Human/AI percentage display) +- Brain status command (Human/AI split) + +## Daemon integration + +The `source` field is sent on every `edit` and `git_commit` event to the daemon. The daemon stores: +- AI commits as `"git commit (ai-assisted)"` in `build_events` +- AI commits also as `ai_events` for analytics weighting +- Completion acceptances as `ai_events` with type `"suggestion_accepted"` + +## Files + +- `src/ai-tracker.ts` — Core tracker (new) +- `src/events.ts` — Wired to classify edits and commits +- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Daemon-side storage + +- **Focus-aware code review CodeLens**: annotations at the top of each file show the developer's focus level when the file was last edited (`⚠ Low Focus (42)`, `ℹ Focus: 65/100`, `🤖 AI-Assisted (85%)`, or none for high focus). `NeuroSkill: Show Files Needing Review` command lists low-focus human-authored files. Toggle via `neuroskill.focusCodeLens`. + +## What you see + +- `⚠ Low Focus (42) — Review Recommended` — File was edited during low focus. Click to see all files needing review. +- `ℹ Focus: 65/100` — Moderate focus, informational only. +- `🤖 AI-Assisted (85%)` — Most edits were AI-generated, focus score not applicable. +- No annotation — High focus (>70) or no data yet. + +## Commands + +**NeuroSkill: Show Files Needing Review** (`Cmd+Shift+P`) +- Shows a QuickPick list of files edited during low focus (<50) that were mostly human-authored +- Sorted by focus score (lowest first) +- Select a file to open it + +## How it works + +- `FocusCodeLensProvider` queries `/brain/cognitive-load` (grouped by file) every 30 seconds +- Combines focus data with `AIActivityTracker.getAIRatioForFile()` to distinguish human vs AI code +- Files with high AI ratio (>70%) show AI label instead of focus score — AI code doesn't reflect human cognitive state + +## Settings + +`neuroskill.focusCodeLens` (default: `true`) — Toggle CodeLens annotations on/off. + +## Files + +- `src/codelens-provider.ts` — CodeLens provider (new) + +- **Smart Interruption Shield**: when `/brain/flow-state` reports `in_flow: true`, VS Code's Do Not Disturb mode auto-enables and the status bar shows `$(shield) In Flow 12m`. `NeuroSkill: Toggle Flow Shield` cycles Auto / Forced-on / Forced-off. Toggle via `neuroskill.flowShield`. + +## How it works + +- When `/brain/flow-state` reports `in_flow: true`, the Flow Shield activates +- Enables VSCode's Do Not Disturb mode (VSCode 1.90+) +- Shows `$(shield) In Flow 12m` in the status bar with elapsed time +- When flow state ends, DND is automatically disabled + +## Manual override + +**NeuroSkill: Toggle Flow Shield** (`Cmd+Shift+P`) + +Cycles through three modes: +1. **Auto** (default) — activates/deactivates based on EEG flow detection +2. **Forced on** — always active regardless of flow state +3. **Forced off** — never active + +## Settings + +`neuroskill.flowShield` (default: `true`) — Enable/disable the flow shield feature. + +## Files + +- `src/flow-shield.ts` — Flow shield implementation (new) +- `src/brain.ts` — Calls `flowShield.update()` every 30s + +- **Adaptive Break Coach**: personalized break timing based on the developer's actual EEG focus cycle (via `/brain/break-timing`), not generic Pomodoro. Status bar countdown `$(clock) Break in 8m`, max one notification per cycle, auto-resets when fatigue indicates idleness. `NeuroSkill: Take a Break` resets the timer manually. Toggle via `neuroskill.breakCoach`. + +## How it works + +- Queries `/brain/break-timing` to learn the developer's natural focus cycle length +- Shows a countdown in the status bar: `$(clock) Break in 8m` +- When the predicted focus drop is imminent (<5 min), the countdown turns visible +- When the cycle ends, shows `$(clock) Break time` and optionally notifies + +## Notifications + +- Max one notification per focus cycle +- Message: "You've been focused for 47m. Your natural cycle is 42m — take a break?" +- Buttons: "Take Break" (resets timer) or "Dismiss" + +## Timer sync + +The break coach automatically syncs with the daemon's fatigue data. If `continuous_work_mins` drops below 5 (indicating the user was idle), the session timer resets — no false break suggestions after returning from lunch. + +## Commands + +**NeuroSkill: Take a Break** (`Cmd+Shift+P`) — Manually acknowledge a break and reset the timer. + +## Settings + +`neuroskill.breakCoach` (default: `true`) — Enable/disable break coaching. + +## Files + +- `src/break-coach.ts` — Break coach implementation (new) +- `src/brain.ts` — Calls `breakCoach.refresh()` and `resetSessionIfIdle()` every 30s + +- **Struggle → AI assist bridge**: when `/brain/struggle-predict` flags a file (EEG focus + undo rate + velocity drop + time-on-file), shows an actionable notification with **Open Copilot Chat**, **Open Terminal**, and **Step Back** buttons. Debounced to one suggestion per file per 10 minutes. Toggle via `neuroskill.struggleBridge`. + +## How it works + +- Monitors `/brain/struggle-predict` (EEG focus + undo rate + velocity drop + time-on-file) +- When `struggling: true`, shows an actionable notification: + > "Stuck on auth.ts? (score: 78) Consider breaking the problem into smaller pieces." + +## Action buttons + +| Button | Action | +|--------|--------| +| **Open Copilot Chat** | Opens GitHub Copilot interactive chat (or generic chat panel) | +| **Open Terminal** | Toggles terminal for CLI debugging | +| **Step Back** | Dismiss and take a mental break | + +## Debouncing + +- Max one suggestion per file per 10 minutes +- Prevents notification fatigue while still catching genuine struggles + +## Settings + +`neuroskill.struggleBridge` (default: `true`) — Enable/disable struggle detection and AI suggestions. + +## Files + +- `src/struggle-bridge.ts` — Struggle bridge implementation (new) +- `src/brain.ts` — Calls `struggleBridge.check()` every 30s (replaces the old generic struggle notification) + +- **Personal Flow Triggers dashboard**: collapsible "Your Flow Recipe" sidebar section mining 7-day EEG + activity history — best language (`/brain/code-eeg`), peak hours (`/brain/optimal-hours`), natural cycle length (`/brain/break-timing`), top flow killer (`/brain/context-cost`). Toggle via `neuroskill.flowTriggers`. + +## What you see + +In the NeuroSkill sidebar panel, a collapsible "Your Flow Recipe" section shows: + +- **Best language** — "Focus best on Rust (82)" — from `/brain/code-eeg` +- **Peak hours** — "Peak hours: 9:00, 10:00, 14:00" — from `/brain/optimal-hours` +- **Natural cycle** — "Natural cycle: 42m" — from `/brain/break-timing` +- **Flow killer** — "Flow killer: Slack (focus 38 at switch)" — from `/brain/context-cost` + +## Data sources + +| Insight | API Endpoint | Time Range | +|---------|-------------|------------| +| Best languages | `/brain/code-eeg` | Last 7 days | +| Peak hours | `/brain/optimal-hours` | Last 7 days | +| Natural cycle | `/brain/break-timing` | Last 7 days | +| Flow killers | `/brain/context-cost` | Last 7 days | + +## Settings + +`neuroskill.flowTriggers` (default: `true`) — Show/hide the flow triggers section in the sidebar. + +## Files + +- `src/sidebar.ts` — `_fetchFlowTriggers()` and `_renderFlowTriggers()` methods + +- **Focus-scored git commits in sidebar**: collapsible "Recent Commits" section shows the last 8 commits with author marker (👤 human / 🤖 AI) and a color-coded focus badge captured at commit time via `/brain/flow-state`. AI-assisted commits show "AI" instead of a focus score. Toggle via `neuroskill.focusCommits`. + +## What you see + +In the NeuroSkill sidebar, a collapsible "Recent Commits" section shows the last 8 commits: + +``` +👤 82 fix: resolve auth race condition +👤 45 chore: update dependencies +🤖 AI refactor: extract helper functions +👤 71 feat: add user preferences +``` + +- **👤** = human-authored commit +- **🤖** = AI-assisted commit (message generated by Copilot) +- **Focus badge** = color-coded: green (>70), yellow (40-70), red (<40) +- AI commits show "AI" instead of a focus score — AI output doesn't measure human cognition + +## How it works + +- When the extension detects a git commit (SCM input box clears), it: + 1. Snapshots current EEG focus via `/brain/flow-state` + 2. Checks `AIActivityTracker.isCommitAIAssisted()` + 3. Records the commit with focus score + source label +- Commits stored in-memory (last 15), refreshed on sidebar render +- The daemon also stores commits with human/AI distinction in `build_events` + +## Settings + +`neuroskill.focusCommits` (default: `true`) — Show/hide the commits section in the sidebar. + +## Files + +- `src/sidebar.ts` — `recordCommit()`, `_renderCommits()` +- `src/extension.ts` — Wires commit detection to sidebar recording +- `src/events.ts` — `onCommit` callback with human/AI source + +- **Optimal Task Router**: monitors flow score every 30 s and, when it changes by >20 points, suggests an appropriate task type (refactoring/new features at high focus, code review at moderate, docs/routine at low). Debounced to one suggestion per 15 minutes. Toggle via `neuroskill.taskRouter`. + +## How it works + +- Monitors the flow state score every 30 seconds +- When focus changes by >20 points from the last reading, suggests an appropriate task type: + +| Focus Level | Suggestion | +|------------|------------| +| >75 | "Focus is high (85) — great time for complex work like refactoring or new features." | +| 45-75 | "Focus moderate (58) — good for code review, testing, or incremental tasks." | +| <45 | "Focus low (32) — consider documentation, routine tasks, or a break." | + +## Debouncing + +- Maximum one suggestion every 15 minutes +- No suggestion on the first reading (establishes baseline) +- No suggestion if focus stays within 20 points of the last reading + +## Settings + +`neuroskill.taskRouter` (default: `true`) — Enable/disable task routing suggestions. + +## Files + +- `src/task-router.ts` — Task router implementation (new) +- `src/brain.ts` — Calls `taskRouter.check()` every 30s + +- **Activity dashboard tab**: new Settings tab with daily summary, productivity score (0-100 composite), hourly heatmap, top files/projects, language breakdown, focus sessions, meetings, and stale file alerts. +- **Focus session replay**: click any session to expand a detailed view with file timeline, edit counts, EEG focus overlay bar, and meeting interruptions. +- **Weekly report with CSV export**: 7-day activity digest with daily breakdown chart and one-click CSV download. +- **Productivity scoring**: composite score from edit velocity + deep work time + context stability + EEG focus. +- **Stale file detection**: files edited but untouched for 7+ days. +- **Code-Brain correlation**: focus ranked by language, project, and individual files — shows which code drains or energizes you. +- **Activity timeline**: unified chronological feed of all events (files, builds, meetings, AI, clipboard) with EEG focus indicators. + +- **Brain awareness API**: 14 endpoints under `/v1/brain/*` — flow state, cognitive load, meeting recovery, optimal hours, fatigue check, undo struggle, daily brain report, break timing, deep work streak, task type detection, struggle prediction, interruption recovery, code-EEG correlation, and unified timeline. +- **Flow state detector**: real-time check if user is in deep focus (high focus + low switches + sustained editing). +- **Task type detection**: auto-classify coding/debugging/reviewing/refactoring/testing from activity patterns. +- **Struggle prediction**: fuse undo rate + velocity drop + focus decline to predict when user is stuck. Includes actionable suggestion. +- **Interruption recovery measurement**: measure actual focus recovery time per interruption source (Slack avg 12min, Zoom avg 23min, etc.). +- **Fatigue monitor**: 15-minute background check broadcasts `fatigue-alert` and sends OS notification when focus declines. +- **Daily brain report**: 6pm OS notification with morning/afternoon/evening brain summary. +- **Weekly digest notification**: Monday 9am OS notification with weekly summary. ISO week dedup prevents re-fire on daemon restart. +- **Break timing optimizer**: detect natural focus cycle length from 5-minute EEG buckets. +- **Deep work streak**: gamified consecutive days with 60+ minutes of deep work. + +- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable EEG-fused insights in one call. +- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — shows how focus level predicts test outcomes. +- **Hourly productivity**: churn volume + undo rate + avg EEG focus by hour of day — identifies peak and trough hours. +- **Context switch recovery**: EEG focus level at each editor/terminal/panel transition — measures the cognitive cost of switching. +- **AI tool impact on focus**: per-app (Claude, Pi) focus delta vs baseline — quantifies whether AI tools help or hurt flow state. +- **Focus by language**: avg EEG focus + undo rate per programming language — identifies which languages are cognitively easiest/hardest. +- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day — shows when edit-test loops are fastest. +- **Tool focus impact**: avg EEG focus per command category (docker, git, deploy, etc.) — reveals which tools correlate with lowest focus. +- **EEG timeseries table**: periodic JSON snapshots of all brain metrics (every 5s during recording). Extensible — new metrics without schema changes. Any event correlatable by timestamp join. +- **EEG timeseries worker**: background thread writes full band powers to `eeg_timeseries` every 5s when an EEG session is active. +- **`POST /v1/brain/eeg-at`**: get EEG metrics at a specific timestamp (nearest sample). +- **`POST /v1/brain/eeg-range`**: get EEG time-series in a range for charts and correlation analysis. +- **Window focus tracking**: `window_focus` events sent when VS Code gains/loses focus. EEG not attributed to coding when VS Code is in background. +- **Live EEG attachment**: `latest_bands` focus/mood injected at event storage time for terminal commands, zone switches, and conversations. + +- **`mlx-e2e` test suite**: new suite in `scripts/test-all.sh` running UMAP and FFT e2e tests with MLX features. Auto-skips on non-macOS. Available via `npm run test:mlx-e2e`. + +- **Conversations table**: stores all AI coding assistant messages (Claude, Pi) with app, role (user/assistant/tool), text, cwd, timestamp, session ID, EEG focus/mood. +- **FTS5 full-text search**: SQLite virtual table for instant keyword search across all conversation text. `POST /v1/brain/search-conversations {"query":"JWT","mode":"fts"}`. +- **Fuzzy search**: LIKE-based substring matching for partial queries. `{"query":"rate limit","mode":"fuzzy"}`. +- **Structured search**: filter by app, role, time range. `{"mode":"structured","app":"claude","role":"user","since":...}`. +- **Semantic search**: user prompts embedded via fastembed (nomic-embed-text-v1.5, local, no API credits) and stored in HNSW label index. Searchable by meaning, not just keywords. +- **Generic embedding store**: `embeddings` table decoupled from specific data tables. Multi-model support — can re-embed with different models, store multiple vectors per item. Source tracking (source_type, source_id). +- **Code context HNSW index**: separate `code_context_index.hnsw` file for code-specific semantic search, keeping EEG label searches uncontaminated. +- **Conversation events**: VS Code extension sends `conversation_message` events to daemon for each Claude/Pi message. User prompts get embedded; assistant responses and tool calls get FTS-indexed only (saves compute). +- **Session tracking**: messages grouped by JSONL filename (session ID). Timestamps from app's own data (ISO 8601 UTC), not extension read time. +- **Embedding settings**: `neuroskill.embedding.maxInputLength` (default 1000 chars) and `neuroskill.embedding.enableConversations` (default true) in VS Code settings. + +- **Meeting detection**: automatically detect Zoom, Teams, Slack, Google Meet, FaceTime, Discord, and Webex meetings from window titles. Track start/end times in `meeting_events` table. +- **Browser tab extraction**: extract page titles from Chrome, Safari, Firefox, Edge, Brave, Arc, Opera, Vivaldi, Chromium. Stored in `browser_title` column on `active_windows`. +- **Clipboard monitoring**: opt-in macOS clipboard change tracking (metadata only — content never stored). Records source app, content type, and size. Includes Automation permission check and settings UI. +- **Multi-monitor awareness**: track windows on secondary monitors across macOS (AppleScript), Linux (wmctrl), Windows (EnumWindows). Dynamic primary screen resolution detection. +- **Undo frequency tracking**: detect undo/redo from file edit diffs via reversal heuristic. `undo_estimate` per 5-second chunk, `undo_count` per file interaction. + +- **Design tokens** (`webview-ui/src/lib/tokens.css`): 30+ CSS custom properties organized into core palette (5 colors), surfaces (4 levels), backgrounds (14 translucent tints), borders (3 colors), semantic aliases (7), and typography (3). Zero inline `rgba()` in App.svelte. +- **Reusable Svelte components** (`webview-ui/src/lib/`): + - `Card` — wrapper with title, info button, info panel, header-right slot, variant borders (warn/danger/info) + - `MetricRow` — label + value pair with variant colors (warn/accent/dim/good/bad) + - `Chevron` — collapsible section with chevron toggle, count badge, slot content + - `ProgressBar` — horizontal bar with value/max, 5 color variants, optional label + - `Gauge` — circular SVG ring with animated fill, value, label + - `Badge` — text badge with 7 variants (default/good/warn/bad/blue/live/score-circle/si) + - `Callout` — alert box with 3 variants (warn/danger/info) +- **Component index** (`webview-ui/src/lib/index.ts`): barrel export for all components. +- **Timestamp compliance**: 25 automated tests (`src/test-timestamps.ts`) verifying: + - No `toLocaleTimeString`/`toLocaleDateString` in data layer (activity-tracker, events, brain, config, vt-parser) + - `toLocaleTimeString` used in UI layer (App.svelte) for display + - `Date.now()` returns UTC milliseconds + - ISO 8601 strings parsed to UTC millis + - No hardcoded timezone offsets in data layer + - All stored timestamps are UTC; local conversion only at UI boundary + +- **Dev loop detection**: identifies edit-build-test cycles from terminal command history. Groups consecutive runs of the same build/test command into loops. +- **`dev_loops` table**: loop type, command, iteration count, pass/fail counts, avg cycle time, fastest/slowest cycle, EEG focus start/end, focus trend (rising/falling/stable). +- **`POST /v1/brain/dev-loops`**: returns detected loops for a time window with all metrics. +- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages." +- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl → infrastructure (0.8), deploy → deploying (0.85), git dominating → git_management (0.7), test → testing (0.85), debug → debugging (0.9). +- **Dev loops in sidebar**: command, iteration count, pass rate, avg cycle time, focus trend arrow. Failing loops get red left border. +- **Dev loops in Tauri Activity tab**: expanded view with pass rate percentage, cycle time, focus trend. +- **Loop efficiency by hour**: insight showing build/test cycle time + pass rate by time of day. + +- **Inference backend selector**: `exg_inference_device` setting expanded from `gpu | cpu` to `auto | mlx | gpu | cpu`. Default changed from `gpu` to `auto` (picks MLX on macOS, GPU elsewhere). +- **Four-button selector in EXG Settings**: Auto, MLX, GPU, CPU — each with localized label and description. Reconnect headset hint shown after change. +- **`embed-exg-mlx` feature**: new daemon Cargo feature that enables `skill-router/mlx`, `skill-eeg/mlx`, and `embed-zuna-mlx` together. + +- **fast-umap 1.6.0**: updated from 1.5.1 with MLX, PCA, and nearest-neighbor descent support. +- **MLX UMAP backend**: Apple Silicon native UMAP projection via `burn-mlx`. Runtime dispatch between MLX and GPU (wgpu) based on user preference. Auto defaults to MLX on macOS, GPU elsewhere. +- **Precision selector**: F32 / F16 precision for the GPU (wgpu) backend. MLX is F32-only (fast-umap trait constraint). Exposed in UMAP settings as chip group. +- **Backend & precision UI**: new "Compute Backend" section in UMAP settings with Auto / MLX / GPU chips and F32 / F16 precision chips. Pipeline summary badge shows active backend and precision. + +- **UMAP e2e benchmarks**: `umap_e2e_small` (200 pts), `umap_e2e_medium` (1K pts), `umap_e2e_large` (5K pts) with synthetic 32-dim EEG embeddings. Reports backend, timing, throughput (pts/sec), and separation score. + +- **gpu-fft 1.2.0**: updated from 1.1.1 with MLX FFT backend. EEG signal filtering (overlap-save convolution) uses MLX when `skill-eeg/mlx` is enabled. ~3.7x faster than wgpu at N=65536. +- **`skill-eeg/mlx` feature**: new feature gate that enables `gpu-fft/mlx` alongside `gpu-fft/wgpu`. Wired into `embed-exg-mlx` daemon feature. + +- **FFT MLX e2e**: `fft_e2e_roundtrip_256`, `fft_e2e_batch_4ch`, `fft_e2e_psd_peak_detection`, `fft_e2e_large_batch` (32 channels x 1024 samples). Validates round-trip accuracy, PSD peak detection, and batch throughput. + +- **Grayscale display mode**: optional system-wide grayscale that activates/deactivates in sync with Do Not Disturb. Reduces visual distraction during deep work. macOS only, default off. + +- **File activity in history sessions**: expanded EEG sessions show files worked on during the session with focus indicators, edit counts (+/-), language, and meeting interruption badges. +- **File activity in interactive search**: `file_activity` nodes in the 3D search graph linked to EEG epochs and text labels, showing which files were being edited during matching brain states. +- **Meeting nodes in search graph**: detected meetings appear alongside EEG results in the interactive search as amber nodes with temporal proximity edges. + +- **Git context card**: current branch (monospace), dirty/staged file count, ahead/behind indicators. Uses VS Code git extension API. +- **Session timeline card**: horizontal bar chart showing activity events by hour of day. Color-coded by volume. +- **Workspace activity card**: per-file edits, lines added/removed, focus time. Grouped by workspace folder (project), sorted by activity. Active file indicator (blue dot). Top 10 files per project. +- **Environment card**: editor/terminal/panel time split (colored stacked bar), tab count, editor groups, terminal count. Per-terminal cards with shell type, CWD, PID, shell integration status. +- **Terminal I/O sections**: collapsible with chevrons, minimized by default. Input shows timestamped commands with CWD. Output shows VT-parsed session content. +- **Terminal input card**: keystroke intensity per program (keys/min), duration, collapsible behind chevron. +- **Terminal impact card**: EEG focus delta by command category with pass rate percentage. +- **Context switch cost card**: focus level at each zone transition type with switch count. +- **Dev loops card**: edit-build-test cycles with iteration count, pass/fail rate, cycle time, focus trend (rising/falling/stable arrow). +- **Today's report card**: productivity score, morning/afternoon/evening period breakdown with focus bars and churn. +- **Energy card**: fatigue bar (inverse of focus decline), continuous work time, streak badge. +- **Struggle card**: EEG-only (hidden without recording), score 0-100, contributing factors. +- **Optimal hours card**: peak/avoid hours grid. +- **AI usage card**: acceptance rate bar, suggestion count, source breakdown. +- **Today vs yesterday card**: files and churn comparison with directional arrows. +- **Code review detection**: auto-detected when file switches > 5 and edit velocity < 1 line/min. +- **Process monitor card**: running dev servers (vite, next, webpack, cargo, node, python, docker, postgres) with ports and PIDs. +- **Info toggles**: every card has a `?` button explaining how metrics are calculated. +- **Dual-speed updates**: local data every 5s (visible), brain data every 30s. Pauses when sidebar hidden. + +- **OS-wide shell hooks**: preexec/precmd hooks for zsh, bash, fish, PowerShell. Background curl sends every command to daemon — zero delay to prompt. Self-contained scripts generated by daemon, stored in `~/.skill/shell-hooks/`. +- **Shell hook management**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check per shell. +- **Terminal command table**: `terminal_commands` with command text, binary name, args, cwd, exit code, duration, auto-categorized (284 CLI tools across 18 categories), EEG focus at start + end for delta calculation. +- **Binary extraction**: raw binary name stored separately from args. Lazy categorization — `POST /v1/brain/recategorize-commands` reruns rules on all historical data. `POST /v1/brain/binary-stats` discovers uncategorized tools by frequency. +- **284 CLI tools recognized**: git, gh, glab, hf, docker, kubectl, helm, aws, gcloud, az, brew, pip, cargo, npm, psql, redis-cli, ollama, claude, pi, vim, tmux, htop, terraform, and 260+ more across 18 categories (build, test, run, git, docker, deploy, install, navigate, debug, network, database, ai, editor, multiplexer, monitor, env, system, other). +- **Script session recording**: `script` PTY proxy wraps shell sessions, captures all terminal I/O (including input inside interactive programs). Logs stored in `~/.skill/terminal-logs/` with auto-rotation. +- **VT100 terminal emulator**: `vt-parser.ts` replays script recordings through a virtual terminal with scrollback capture. Handles cursor movement, screen clears, alternate screen buffer. Separates input from output via prompt pattern detection. +- **App-specific JSONL parsing**: reads Claude Code (`~/.claude/projects/`) and Pi agent (`~/.pi/agent/sessions/`) conversation files directly. Extracts user prompts with real timestamps, assistant responses, and tool calls. Supports multiple parallel sessions. +- **Readline history polling**: monitors `~/.python_history`, `~/.node_repl_history`, `~/.irb_history`, `~/.psql_history` for REPL input. +- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). +- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons with health indicators (green/yellow/red dot), recent commands preview, "How it works" documentation. + +- **Validation / fatigue-research daemon backend**: foundations for calibrating the Break Coach and Focus Score against four external instruments — KSS (Karolinska Sleepiness Scale), NASA-TLX (workload), PVT (Psychomotor Vigilance Task), and an EEG-derived fatigue index per Jap et al. 2009. +- **`skill-data::validation_store`** — new SQLite store at `~/.skill/validation.sqlite` with five tables (`config`, `kss_responses`, `tlx_responses`, `pvt_runs`, `prompt_log`). Persistent config is a single-row JSON blob with serde defaults so adding a new channel later is a non-migration. New constant `VALIDATION_FILE` in `skill-constants`. +- **EEG fatigue index**: pure function `eeg_fatigue_index(bands) → Option` computing `(α + θ) / β` over the existing band-power snapshot. Guards against missing bands and zero-β. Passive; ships **on** because it costs nothing when no headset is attached. +- **Pure scheduler `decide_prompt(ctx, ...) → PromptDecision`**: respects the `respect_flow` master gate, configurable quiet hours, per-channel daily caps, runtime snoozes, and rate limits. Channel ordering: KSS first (lightest), TLX after a long task unit, PVT on a weekly cadence. Live `read_in_flow` and `read_break_coach_active` open the activity store read-only and ask `flow_state_now(300).in_flow` and `fatigue_check().fatigued` so the gates actually mean something. +- **Sane defaults — opt-in everywhere**: KSS, TLX, PVT all ship `enabled = false`. Only the passive EEG fatigue index ships on. The `respect_flow` master gate ships on. + +- **Validation & Research settings tab** (`src/lib/settings/ValidationTab.svelte`): new top-level settings tab gathering five `SettingsCard` blocks — global gates (`respect_flow`, quiet hours), KSS, NASA-TLX, PVT, and EEG fatigue index — plus a Calibration Week button and a recent-results card. + - Every toggle / numeric input PATCHes one nested field via `daemonPatch("/v1/validation/config", …)`; the daemon's recursive JSON merge keeps the rest of the config untouched. + - EEG fatigue card shows the live `(α + θ) / β` value polled every 5 s from `/v1/validation/fatigue-index`. + - Calibration Week: single button that batch-updates all four channels to higher-frequency presets (KSS 8/day, TLX after every flow block ≥ 20 min, PVT mid-week) in one PATCH. +- **PVT panel** (`src/lib/settings/PvtPanel.svelte`): full 3-minute Psychomotor Vigilance Task. Random ITIs (2–10 s), green-dot stimulus, `performance.now()` for sub-millisecond RT measurement, tracks responses + false starts. On finish computes mean RT, median RT, slowest-10% mean RT (anticipation-resistant), and lapse count (RT > 500 ms per Dinges & Powell 1985), POSTs to `/v1/validation/pvt`. State machine: intro → running → done. +- **NASA-TLX form** (`src/lib/settings/TlxForm.svelte`): six-slider modal for the raw (un-weighted) NASA-TLX. Performance scale uses inverted endpoints ("Failure" → "Perfect") per Hart 2006. POSTs to `/v1/validation/tlx` with `task_kind`, `task_duration_secs`, optional `prompt_id` echo-back. +- **Pure stats helper** (`src/lib/settings/pvt-stats.ts`): extracted `mean`, `median`, `slowest10Mean`, `lapseCount`, `computeStats` from the PVT panel so the math is unit-testable without a Svelte renderer. +- **`daemonPatch(path, body)`** in `src/lib/daemon/http.ts` — needed for the validation config endpoint. + +- **Test coverage for the validation feature across all three layers** — 72 tests total, all passing. + - **Rust unit tests** (`crates/skill-data/src/validation_store.rs`): 19 tests covering config persistence, store round-trips, the `(α + θ) / β` fatigue index (Jap et al. 2009) including zero-β and missing-band edge cases, every scheduler branch (flow respect, quiet hours, snooze blocking, KSS rate limit, TLX trigger after long task, TLX skip on short task, PVT weekly cadence, PVT silence after recent run, quiet-window-equals-end semantics), and prompt-log queries. + - **Rust HTTP integration tests** (`crates/skill-daemon/src/routes/validation.rs`): 10 tests using `tower::ServiceExt::oneshot()` against a tempdir-backed `AppState` — `GET /config` defaults, `PATCH /config` partial-update + round-trip, `POST /kss` happy path, KSS score-out-of-range → 400, TLX subscale-out-of-range → 400, `GET /should-prompt` returns `{kind: "none"}` with default config, `POST /snooze` envelope, plus three `merge_json` tests covering deep nesting. + - **Vitest — PVT statistics** (`src/tests/pvt-stats.test.ts`, 12 tests): edge cases for `mean`, `median`, `slowest10Mean` (including the n<10 fallback), `lapseCount` with the 500 ms Dinges & Powell threshold and explicit-threshold override, plus a known-fixture `computeStats` round-trip. + - **Vitest — i18n coverage** (`src/tests/validation-i18n.test.ts`, 23 tests): every VS Code l10n bundle exists and contains every user-facing key (KSS prompt + scores 1/5/9, TLX/PVT prompts, all four escape-hatch labels); every Tauri language `validation.ts` imports cleanly; English Tauri bundle covers every required key; every non-English Tauri bundle has at least the tab name and disclaimer translated. + +- **VS Code extension joins the validation prompt loop**: new `ValidationManager` (`extensions/vscode/src/validation.ts`) polls `/v1/validation/should-prompt` every 90 s and renders whichever channel the daemon decides to fire. + - **KSS** (1–9 sleepiness) — full QuickPick with Karolinska wording, plus Snooze 30m / Don't ask today / Stop these prompts escape hatches in-line. POSTs the answer to `/v1/validation/kss` echoing the daemon's `prompt_id` so the prompt log can mark it answered. + - **NASA-TLX** — fallback path: shows an information message offering to open the form in the Tauri app (deep link `neuroskill://validation/tlx`) plus the same escape hatches. + - **PVT** — weekly nudge: offers to deep-link into the Tauri PVT panel (`neuroskill://validation/pvt`) or skip a week (snoozes for 6 days so the next reminder fires on cadence). +- **Two new commands**: `NeuroSkill: Open Validation Settings…` (deep links into the Tauri preferences pane) and `NeuroSkill: Check for Validation Prompt Now` (forces a single scheduler poll — useful for opt-in onboarding). +- **`DaemonClient.patch()`**: extension's daemon client gained a PATCH method so the "Stop these prompts" escape hatch can flip `enabled = false` on the persistent config. + +- **VS Code extension**: separate repo (`NeuroSkill-com/vscode-neuroskill`) as git submodule at `extensions/vscode/`. 50+ tracked event types across editing, navigation, debugging, git, AI, terminal, clipboard, and more. +- **Sidebar webview (Svelte 5)**: full brain dashboard in the VS Code activity bar with circular flow gauge, metrics strip, daily report, energy, struggle, optimal hours, workspace activity, environment, terminal impact, context cost, and dev loops. +- **Activity bar icon**: neural network SVG icon in the VS Code sidebar; NeuroSkill logo (PNG) in the webview header. +- **Brain status bar in VS Code**: polls daemon every 30s showing flow state, fatigue, streak, task type, and struggle score. Shows "offline" when daemon unreachable. Notifications for fatigue and struggle. +- **Dual-speed sidebar updates**: local data (files, terminals, layout) refreshes every 5s when sidebar is visible; brain endpoints every 30s. Pauses when hidden. +- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk via `globalStorageUri`. Auto-flushes on reconnect. Survives VS Code restarts. Cache count shown in status bar tooltip. +- **Workspace activity tracking**: per-file edits, lines added/removed, focus time, active file indicator. Grouped by workspace folder (project), sorted by activity. Top 10 files per project. +- **Terminal command tracking**: full command text, exit code, cwd, output streaming (via `execution.read()`), shell type detection (zsh/bash/fish/powershell/cmd/node/python), focus time per terminal, PID. Expandable command output in sidebar. +- **Terminal shell integration**: captures commands via `onDidStartTerminalShellExecution` / `onDidEndTerminalShellExecution` (VS Code 1.93+). CWD tracked via `onDidChangeTerminalShellIntegration`. +- **Zone tracking**: editor/terminal/panel time split with stacked color bar (blue/green/yellow). Layout chips showing tab count, editor groups, terminal count. +- **Auto EEG labeling**: every significant VS Code event auto-inserts a searchable label into EEG recordings with smart categorization (editing, debugging, git commits, AI assistance, meetings, errors, navigation, terminal commands, zone switches). +- **Inline label embedding**: auto-labels embedded immediately via fastembed for instant searchability (no idle reembed wait). +- **Command execution tracking**: 40+ VS Code commands tracked (go-to-definition, rename, find, format, fold, git, AI, debug, layout) with semantic categorization. +- **IntelliSense acceptance detection**: multi-char single-line insertions heuristically identified as autocomplete acceptances. +- **Clipboard tracking**: clipboard content changes polled every 5s with debounce. +- **Layout snapshots**: periodic (60s) capture of editor groups, visible editors, open tabs, terminal count sent to daemon. +- **Zone switch events**: editor/terminal/panel transitions sent to daemon with EEG focus snapshot. +- **File system watcher**: selective watching of package.json, Cargo.toml, go.mod, .git/HEAD for external change detection. +- **Environment context**: one-time capture of appHost, remoteName, shell, uiKind, language. +- **Info toggles**: every sidebar card has a `?` button explaining how metrics are calculated (flow score formula, struggle signals, energy bar, optimal hours, dev loops, terminal impact, context cost). +- **Open NeuroSkill button**: launches native app from sidebar (cross-platform: `open -a` macOS, `start` Windows, `xdg-open` Linux). Also in command palette. +- **Command palette → sidebar**: `Show Brain Status`, `Today's Report`, `Am I Stuck?`, `Best Time to Code` now open sidebar and scroll to relevant section instead of showing toast notifications. + +- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable insights in one call. +- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — "your tests fail 45% more when focus is low." +- **Hourly productivity**: churn + undo rate + avg focus by hour of day — "you write 3x more bugs after 2pm." +- **Context switch recovery**: focus level at editor/terminal/panel transitions — "switching to terminal costs 4 focus points." +- **AI tool impact**: Claude/Pi focus delta vs baseline — "Claude conversations drop focus by 8 points." +- **Focus by language**: avg EEG focus + undo rate per programming language — "best focus in Rust, worst in CSS." +- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day. +- **Tool focus impact**: avg focus when using each command category (docker, git, deploy, etc.). + +- **Widget accessibility and localization**. + +- **Analysis widgets (Optimal Hours, Daily Report, Weekly Trend)**. + +- **Biometric widgets (Heart Rate, Cognitive Load, EEG Band Power, Sleep)**. + +- **Brain Dashboard widget (medium)**. + +- **Calendar Mind State widget (large)**. + +- **Widget deep links (neuroskill:// URL scheme)**. + +- **Widget development infrastructure**. + +- **Core desktop widgets (Focus, Streak, Session, Break Timer)**. + +- **Interactive widget buttons (macOS 14+)**. + +- **Widget offline data caching**. + +- **Widget timeline reload on state changes**. + +- **zuna-rs 0.1.4**: updated from 0.1.3 with native MLX backend for EEG embedding inference. +- **`embed-zuna-mlx` feature**: new `ZunaMlxState` struct with `ZunaEncoder`. Adds `load_zuna_mlx()` and `encode_zuna_mlx()` functions matching the existing GPU/CPU variants. +- **Load priority**: when user selects Auto or MLX, encoder loading tries MLX first, then GPU f16, then GPU f32, then CPU. + +### Performance + +- **MLX vs GPU benchmarks** (Mac mini, Apple Silicon): + +| Dataset | Points | GPU (wgpu) | MLX | Speedup | +|---|---|---|---|---| +| Small | 200 | 120.9 s | 2.3 s | **51x** | +| Medium | 1,000 | 136.6 s | 7.1 s | **19x** | +| Large | 5,000 | 152.8 s | 23.8 s | **6.4x** | + +- **`open_readonly` for read-only handlers**: added `ActivityStore::open_readonly()` that skips 15+ ALTER TABLE migrations and opens in read-only mode. Switched 38+ HTTP handlers and 3 background tasks from `open` to `open_readonly`. +- **`PRAGMA busy_timeout=5000`**: added to `init_wal_pragmas` and `util::open_readonly` so all database connections (activity, labels, screenshots, EEG embeddings) wait up to 5 s on lock contention instead of failing immediately with SQLITE_BUSY. +- **Composite indexes**: added `(seen_at, file_path)` and `(seen_at, project)` indexes on `file_interactions` for queries that filter by time range and group by path or project. +- **Lock consolidation**: `productivity_score` reduced from 3 separate mutex acquisitions to 1 via internal `_q` query helpers. `weekly_digest` reduced from 12 to 4 locks. Introduced `daily_summary_q`, `context_switch_rate_q`, `get_focus_sessions_in_range_q` static methods that take `&Connection` directly. +- **`PRAGMA optimize` after pruning**: runs after hourly retention pruning to keep query planner statistics fresh. +- **Batch label lookup in interactive search**: replaced 15 per-epoch `get_labels_near` calls (each opening a new DB connection) with a single `get_labels_near_batch` call per text label, then in-memory filtering. Reduces search DB round-trips from ~28 to ~16. + +### Bugfixes + +- **Daemon-side event storage**: `git_commit`/`push`/`pull`/`checkout`/`stage`/`unstage`/`stash` events were received but never written to `build_events` (used only for EEG labels). Now persisted, with AI-assisted commits also recorded as `ai_events` (type `"ai_commit"`) and labelled `"git commit (AI)"`. +- **Completion-accepted events were dropped**: previously ignored by the daemon. Now stored as `ai_events` (type `"suggestion_accepted"`) and as edit chunks so they show up in code metrics and AI usage analytics. + +## Impact on analysis + +Brain analysis endpoints can now: +- Count human vs AI commits (`/brain/developer-insights`) +- Track AI suggestion acceptance rates (`/brain/ai-usage`) +- Include git activity in the activity timeline +- Weight human-authored code differently from AI output in focus/productivity scores + +## Files + +- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Event handler updates + +- **Schema migration**: ALTER TABLE for existing databases missing columns added across releases. Runs before DDL to handle all legacy schemas. +- **Retention pruning**: meetings, clipboard, and secondary_windows pruned alongside file interactions during hourly maintenance. + +- **Dismiss stale Dependabot alerts**: crossbeam-deque, crossbeam-utils, crossbeam-queue, memoffset, and glib alerts dismissed (already at patched versions or fixed in fork). + +- **CORS allow-methods now includes `PATCH`**: `crates/skill-daemon/src/main.rs` was advertising only `GET, POST, PUT, DELETE, OPTIONS`, so the browser preflight blocked `PATCH /v1/validation/config` from the Tauri webview. Added `Method::PATCH` to the list. + +- **Equal padding on screenshot edges**: viewport width was 360px while the body was 320px wide, leaving an asymmetric 40px right gutter. Matched viewport to body width so left and right gutters are now identical. + +- **Deadlock in daily-report endpoint**: `daily_brain_report` held the database mutex while calling `productivity_score`, which re-acquires the same mutex. Scoped the lock to drop before the nested call. +- **Deadlock in struggle-predict endpoint**: same pattern in `predict_struggle` — held mutex while calling `get_recent_files`. +- **`overall_focus` returned `-0.0`**: always wrapped in `Some` even with no EEG data. Now returns `null` when no focus samples exist. +- **`best_period` arbitrary without EEG**: picked the first SQL result when all `avg_focus` were `None`. Now returns empty string when no EEG data to compare. +- **`weekly_avg_deep_mins` returned `-0.0`**: divided by hardcoded 7 regardless of actual days. Now divides by actual day count, returns `0.0` when empty. +- **Notification text ugly without EEG**: daily report notification showed "Best: . Score: 25. Focus: 0." — now conditionally includes only available fields. +- **Brain endpoints return HTTP 200 on errors**: all 18 brain handlers silently returned `null` with 200 OK when the activity store was offline or a query failed. Refactored to use `run_query` helper that returns 503 (db_unavailable) or 500 (task_error) with structured `ApiError` JSON. + +- **Worker thread panic recovery with auto-restart**: all 4 activity worker threads (poller, input monitor, file watcher, clipboard monitor) now wrapped in `catch_unwind` via `spawn_resilient`. On panic, the worker logs the error and restarts after 5 s with freshly cloned `AppState` and `Arc`. +- **osascript timeout**: all `osascript` calls (active window poll, secondary windows, clipboard monitor) now use a 3-second timeout via `run_osascript` helper. Previously, a hung app could block the poller thread indefinitely, silently killing all activity tracking. +- **Daily report catch-up on late startup**: if the daemon starts after 18:00 local, the daily brain report fires on the first tick instead of waiting up to 1 hour for the hourly check. +- **WAL checkpoint on shutdown**: daemon now runs `PRAGMA optimize` on the activity database during graceful shutdown, ensuring the WAL is checkpointed and the database is clean for next start. +- **Missing WAL pragmas on EEG embedding store**: `day_store.rs` opened connections without `init_wal_pragmas`, missing both WAL mode and `busy_timeout`. +- **Retention pruning for new tables**: added `prune_terminal_commands`, `prune_ai_events`, `prune_zone_switches`, `prune_layout_snapshots` — wired into the hourly maintenance cycle so these tables don't grow unbounded. +- **TOCTOU race in calibration settings**: concurrent profile CRUD could lose writes. Added `modify_settings_blocking()` helper that runs under a global mutex on tokio's blocking thread pool — serializes read-modify-write without blocking the async runtime. + +- **Duration-averaged EEG**: `FileSnapshot` now samples EEG focus/mood every 5 s (at each edit-chunk tick) and writes the average to `file_interactions` at finalize, replacing the single snapshot captured at file-switch time. Falls back to the initial snapshot if no samples were collected (`COALESCE`). +- **EEG mood/focus sample count mismatch**: both `FileSnapshot` and `build_focus_sessions` used a single counter for focus and mood samples, inflating mood averages when they arrived independently. Split into separate `focus_count` and `mood_count`. +- **EEG ghost data after disconnect**: `latest_bands` was not cleared when an EEG device disconnected, causing stale focus/mood values to persist in reports and the activity pipeline. Now cleared on all 3 disconnect paths (idle timeout, stream end, explicit disconnect). + +- **InteractiveGraph3D event listener leaks**: `pointerdown` and `dblclick` listeners on the WebGL canvas were never removed in `onDestroy`. Extracted into named refs with cleanup. +- **UmapViewer3D event listener leaks**: 4 canvas event listeners (pointermove, pointerleave, pointerdown, pointerup) added as anonymous functions were never removed on unmount. Extracted into named module-level refs with removal in `onDestroy`. +- **ActivityTab brain polling leak**: `startBrainPolling()` was called on mount but `stopBrainPolling()` was never called on destroy, leaving a 30-second interval running after leaving the tab. +- **Brain polling stopped by peer component**: `stopBrainPolling()` unconditionally killed the interval even when other components still needed it. Added reference counting so polling only stops when the last consumer unmounts. +- **Memory leak in terminal_command_end labeling**: `Box::leak` was used to format non-zero exit codes in EEG auto-labeling, permanently leaking memory for every failed command. Replaced with owned `String`. +- **Screenshot encode failures untracked**: `encode_webp` failures (disk full, I/O error) silently continued without incrementing the `capture_errors` counter. Now counted so metrics reflect actual failures. +- **401 auto-retry**: daemon HTTP client now retries once with a fresh token on 401 (stale token after daemon restart) instead of failing immediately. + +- **Path traversal in `delete_session`**: `csv_path` parameter was passed unsanitized to `fs::remove_file`. Now validates the path is within `skill_dir` via canonicalize + starts_with. +- **Timing-vulnerable token comparison**: default bearer token checks in auth middleware used `==` (variable-time). Switched to `constant_time_eq` for both in-memory and on-disk token paths. +- **Unbounded CSV memory in EXG loader**: loading a CSV with millions of rows would allocate unlimited RAM. Added a 4M row cap (~4.3 hours at 256 Hz). + +- **Timezone-wrong period classification**: `daily_brain_report`, `hourly_edit_heatmap`, and `optimal_hours` used `seen_at % 86400` (UTC hour) instead of local hour. Daily report now uses `seen_at - day_start`; heatmap and optimal-hours accept a timezone offset computed server-side. +- **UTC midnight in all clients**: search page, CLI (`cmdActivity`, `cmdBrain`), VS Code extension, and Swift widget all computed `day_start` as UTC midnight instead of local midnight. Fixed to use `Date.setHours(0,0,0,0)` (JS/TS) and `Calendar.startOfDay` (Swift). +- **Widget empty daily-report body**: `DaemonClient.fetchDailyReport()` sent an empty POST body; the endpoint requires `dayStart`. Now sends local midnight. +- **Widget UTC sleep/calendar endpoints**: `fetchSleep()` and `fetchCalendarEvents()` used `% 86400` UTC approximations. Now use `Calendar.current` for correct local time boundaries. + +### Refactor + +- **Shared `DaemonClient` for VS Code extension**: extracted the repeated `fetch` + auth-token + port-discovery + 3 s timeout pattern into one class. All features now use `client.post(path, body)`. Returns `null` on failure (never throws), with `setToken()` for refresh on reconnect. + +## Before + +Every component (brain.ts, sidebar.ts, extension.ts) independently constructed fetch calls: +```typescript +const port = await discoverDaemonPort(config); +const base = `http://${config.daemonHost}:${port}/v1`; +const headers = { "Content-Type": "application/json" }; +if (token) headers["Authorization"] = `Bearer ${token}`; +const resp = await fetch(`${base}${path}`, { method: "POST", headers, body, signal: AbortSignal.timeout(3000) }); +``` + +## After + +```typescript +const client = new DaemonClient(config, token); +const result = await client.post("/brain/flow-state", { windowSecs: 300 }); +``` + +## Benefits + +- Single place to update auth, timeout, port discovery +- All 8 new features use the shared client +- `setToken()` method for token refresh on reconnect +- Returns `null` on any failure (never throws) — all features handle gracefully + +## Files + +- `src/daemon-client.ts` — DaemonClient class (new) + +- **Sidebar disclaimer test now accepts either localiser**: existing `vscode-sidebar-disclaimer.test.ts` updated so the `vscode.l10n.t("sidebar.disclaimer")` ↔ `tr("sidebar.disclaimer")` migration doesn't break the assertion. Both look up the same key. + +- **Conversations table + FTS5**: full-text search on all AI conversation messages (user/assistant/tool). Three search modes: FTS, fuzzy, structured. +- **EEG timeseries table**: periodic JSON snapshots of all brain metrics. Extensible — new metrics without schema changes. Join-at-query-time correlation with any event. +- **Embeddings store**: generic, multi-model. User prompts embedded via fastembed (local, no API credits). Can re-embed with different models. +- **Code context HNSW index**: separate from label index for code-specific semantic search. +- **Binary extraction**: terminal commands store raw binary name + args separately. Lazy categorization — 284 CLI tools recognized, rerun anytime. +- **EEG attachment**: live focus/mood from `latest_bands` injected at event storage time. +- **EEG timeseries worker**: writes full band powers to `eeg_timeseries` every 5s during recording. + +### Build + +- **CI for the VS Code extension** (`extensions/vscode/.github/workflows/`): two workflows live in the submodule repo (`vscode-neuroskill`). + - **`ci.yml`** — runs on every PR and `main` push. `npm ci` → `tsc` build → `vsce package` → uploads the `.vsix` as a 14-day artifact. No secrets, no publish. + - **`release.yml`** — fires two ways. *Tag push* (`git push --tags` after `npm version patch`) verifies the tag matches `package.json` and publishes. *Manual dispatch* with a `patch | minor | major | x.y.z` input bumps `package.json`, commits, tags, pushes, then publishes. Both paths run `npx @vscode/vsce publish` (uses `VSCE_PAT`) and `npx ovsx publish` (uses `OVSX_PAT`), then create a GitHub release with the `.vsix` attached and the latest `## ` block from `CHANGELOG.md` as release notes. +- **Skip-on-missing-secret**: if either `VSCE_PAT` or `OVSX_PAT` is unset, the corresponding publish step emits a `::warning::` and is skipped — letting you wire up Open VSX later without breaking the workflow. +- **Releasing section in the extension README**: documents the secret setup (Azure DevOps PAT, Open VSX token), the two release flows, and the one-time namespace claims (Marketplace publisher registration + `ovsx create-namespace`). + +- **macOS: link libusb statically into `skill-daemon`**: vendored libusb via `rusb`'s `vendored` feature in `crates/skill-devices`. The `antneuro` USB driver pulls in `rusb` → `libusb1-sys`, which by default uses `pkg-config` to find a system libusb-1.0.dylib. On the macOS-26 release runner that resolved to Homebrew's `/opt/homebrew/opt/libusb/lib/libusb-1.0.0.dylib`, so end users without Homebrew hit a launch-time `dyld: Library not loaded` error. Cargo feature unification now flips `libusb1-sys/vendored`, which compiles libusb from source and links `libusb-vendored.a` into the binary — verified by 105 `_libusb_*` text symbols present in the release binary and zero libusb references in `otool -L`. + +### CLI + +- **`neuroskill activity` expanded**: 19 subcommands — bands, window, input, windows, files, top-files, top-projects, languages, sessions, meetings, clipboard, builds, heatmap, switches, summary, score, digest, stale, timeline. +- **`neuroskill brain` added**: 14 subcommands — flow, load, load-files, recovery, optimal, fatigue, struggle, report, breaks, streak, task, stuck, interruptions, correlation. +- **`neuroskill vscode` added**: auto-build and install VS Code/VSCodium/Cursor extension from source on macOS, Linux, and Windows. + +### UI + +- **EEG focus timeline heatmap**: collapsible "Focus Timeline" sidebar section renders a ~280×36 px SVG sparkline of today's EEG focus (`/brain/eeg-range`, max 120 points), color-graded green/yellow/red, with hour labels and file-name annotations at peaks/valleys (merged from `/activity/timeline`). Toggle via `neuroskill.eegHeatmap`. + +## What you see + +In the NeuroSkill sidebar, a collapsible "Focus Timeline" section shows: + +- A ~280px wide, ~36px tall SVG sparkline +- Color gradient: green (>70 focus), yellow (40-70), red (<40) +- Hour labels along the bottom (0:00, 3:00, 6:00, ...) +- File names annotated at focus peaks and valleys + +## Data sources + +| Data | API Endpoint | +|------|-------------| +| EEG time-series | `/brain/eeg-range` (today, max 120 points) | +| File context | `/activity/timeline` (today, last 200 events) | + +The heatmap merges EEG data points with the closest timeline events to show which files correspond to focus peaks and dips. + +## Settings + +`neuroskill.eegHeatmap` (default: `true`) — Show/hide the heatmap in the sidebar. + +## Files + +- `src/sidebar.ts` — `_fetchHeatmap()` and `_renderHeatmap()` methods + +- **Brain status bar**: persistent bottom bar in Tauri app showing flow state, fatigue, streak, edit velocity, and focus score. +- **Brain dashboard card**: flow/fatigue/streak + focus progress bar on the EEG dashboard alongside existing BrainStateScores. +- **Brain indicators in ActivityTab**: flow state card (green), fatigue alert card (amber), and streak card (violet) at top. + +- **Tauri Brain Insights section** in Activity tab: test failure rates by focus level, focus by language bars, AI impact delta, tool focus grid, hourly productivity chart. +- **Tauri EEG focus inline**: terminal commands and conversation messages show EEG focus score (green/yellow/red) at the moment they occurred. +- **VS Code Brain Insights card**: test failure by focus chips, focus by language bars, tool impact chips. Only shown when EEG data is available. +- **VS Code Struggle card**: hidden when no EEG recording (focus is EEG-only, not activity-based). +- **VS Code Flow gauge label**: shows "focus" with EEG, "activity" without — no false claim of brain measurement. + +- **Tauri AI Conversations section** in Activity tab: timestamped message thread with role icons (user/assistant/tool), app name, EEG focus score, scrollable. +- **VS Code Conversations card**: timestamped messages with role icons, app badge, collapsible. +- **VS Code AI Usage card**: Copilot/Codeium acceptance rate bar, suggestion count, source breakdown chips. + +- **Search mode dropdown**: replaced 4-tab bar with a styled dropdown supporting 7 search modes — Interactive, EEG, Text, Images, Code, Meetings, Brain. +- **Diamond EEG nodes**: EEG epochs rendered as faceted octahedrons (diamonds) in the 3D search graph. Meeting nodes rendered as tetrahedrons (pyramids). Visual distinction by data type. +- **New search modes**: Code (file activity + brain state), Meetings (meeting events + recovery), Brain (flow/fatigue/struggle insights). +- **Terminal commands in Cmd-K**: added 4 terminal commands to the command palette — Recent Terminal Commands, Terminal Focus Impact, Context Switch Cost, Dev Loops. Translated labels in all 9 locales. + +- **Configurable alerts**: `neuroskill.alerts.focusLow` (warn when EEG focus drops below threshold), `neuroskill.alerts.continuousWorkMins` (warn after N minutes continuous work). +- **Daily digest notification**: at configurable time (default 17:30), shows productivity score + stats. "Full Report" button opens detail panel. +- **Full report panel**: `Cmd+Shift+R` opens full-width webview with same data as sidebar. +- **Keyboard shortcuts**: `Cmd+Shift+N` (toggle sidebar), `Cmd+Shift+R` (full report), `Cmd+Shift+Alt+N` (reconnect). +- **Open NeuroSkill button**: launches native app (cross-platform). +- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk. Auto-flushes on reconnect. + +- **Spacing pass on the validation tab and modals**: every card uses `flex flex-col gap-5 p-6` instead of the old `space-y-4` (which provided no padding override). Conditional sub-settings under a channel toggle are now indented under a left border (`ml-1 border-l-2 border-border pl-5`) so the parent/child relationship reads visually. Modals padded to `p-8` with `gap-3` button rows. TLX sliders gained `mt-1` lift off the description and `uppercase tracking-wide` low/high legend labels. + +- **Design tokens** (`tokens.css`): 30+ CSS custom properties — palette, surfaces, backgrounds, borders, semantic, typography. Zero inline rgba() in sidebar. +- **7 reusable Svelte components**: Card, MetricRow, Chevron, ProgressBar, Gauge, Badge, Callout. +- **Timestamp compliance**: 25 tests verifying UTC in data layer, local conversion only in UI. + +- **Theme-aware sidebar screenshots in the README**: every screenshot now ships in two variants (`*-dark.png`, `*-light.png`) and is embedded via a `` element with `prefers-color-scheme` sources. GitHub serves the variant matching the reader's OS theme; the marketplace gets the dark fallback. Six states captured: in-flow, stuck, fatigued, low-focus / off-peak, daemon-disconnected, status-bar strip. +- **Sidebar mock library + Playwright generator**: HTML stubs in `extensions/vscode/media/preview/` render the sidebar webview offline (no daemon required). A shared `_styles.css` drives both themes via a `:root.light` override; `npm run screenshots` (Playwright) toggles `` between captures and writes 12 PNGs to `media/screenshots/`. +- **Marketplace README pipeline**: `scripts/build-marketplace-readme.mjs` rewrites every relative `media/...` path (including ``, which `vsce` doesn't touch) to absolute GitHub raw URLs at package time. The source `README.md` keeps relative paths for GitHub + offline viewing; the generated `.marketplace.readme.md` is what `vsce package --readme-path` ships. + +- **Sidebar webview now renders correctly in light themes** (`extensions/vscode/src/sidebar.ts`). Three theme-fragile spots fixed: + - `.metric-card` background switched from `--vscode-sideBar-background` (which is the same colour as the surrounding sidebar — cards collapsed into a faint border in light mode) to `--vscode-editorWidget-background`, with `--vscode-input-background` and a translucent grey as cascading fallbacks. + - `.ai-bar` track switched from `--vscode-panel-border` to `--vscode-progressBar-background` so the empty bar is visible against a white background. + - Row-divider opacity bumped from 0.08 to 0.18; added `:last-child { border-bottom: none }` on commit and AI-metric rows so the last row doesn't double up against the disclaimer footer. +- **State colours stay hard-coded** — red ring for stuck, green for flow, amber for warning. They're semantic, not chrome, and they read fine on both themes. + +### Server + +- **`POST /v1/activity/shell-command`**: receives commands from shell hooks with command text, cwd, shell type, exit code. +- **`POST /v1/brain/terminal-commands`**: query recent commands with exit codes, durations, categories, EEG correlation. +- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category. +- **`POST /v1/brain/binary-stats`**: usage frequency per binary for discovering uncategorized tools. +- **`POST /v1/brain/recategorize-commands`**: rerun categorization rules on all historical commands. +- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end. +- **Zone switch events**: editor/terminal/panel transitions stored with EEG focus snapshot. `POST /v1/brain/context-cost` returns focus at each transition type. +- **Layout snapshots**: periodic (60s) tab/group/terminal counts from VS Code. + +- **`/v1/validation/*` HTTP endpoints** (axum): `GET /config`, `PATCH /config` (recursive JSON merge for partial updates), `POST /snooze`, `POST /disable-today`, `GET /should-prompt` (returns the daemon's prompt decision; logs the fire so the rate-limiter sees it), `POST /kss`, `POST /tlx`, `POST /pvt`, `POST /close-prompt`, `GET /results`, `GET /fatigue-index` (live `(α+θ)/β` from the latest band snapshot). +- **`AppState.validation_runtime`** (`crates/skill-daemon-state/src/state.rs`): new `Arc>` field holding ephemeral snooze/disable-today state. Resets on daemon restart by design — a crash loop shouldn't keep prompts suppressed forever. + +- **`terminal_commands` table**: shell command text, cwd, exit code, duration, auto-categorized (50+ patterns: build/test/run/git/docker/deploy/install/navigate/debug/network/other), EEG focus at start and end for delta calculation. +- **`dev_loops` table**: edit→build/test cycle tracking with iteration count, pass/fail rate, avg cycle time, focus trend (rising/falling/stable). +- **`zone_switches` table**: editor/terminal/panel transitions with EEG focus snapshot at moment of switch. +- **`layout_snapshots` table**: periodic tab/group/terminal counts from VS Code. +- **Event handler**: new match arms for `terminal_command_start`, `terminal_command_end`, `zone_switch`, `layout_snapshot` in `activity_vscode_events_impl()`. +- **Command categorizer**: `categorize_command()` with 50+ patterns across build, test, run, git, docker, deploy, install, navigate, debug, network. +- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category — shows how builds, tests, git, docker affect brain state. +- **`POST /v1/brain/context-cost`**: focus level at each zone transition type with switch counts. +- **`POST /v1/brain/dev-loops`**: edit-build-test cycle detection with iterations, pass rate, cycle time, focus trend. +- **`POST /v1/brain/terminal-commands`**: recent commands with exit codes, durations, categories, EEG correlation. +- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages". +- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl→infrastructure (0.8), deploy→deploying (0.85), git dominating→git_management (0.7), test→testing (0.85), debug→debugging (0.9). +- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end, `"switched to terminal"` on zone changes. +- **AI events table**: `ai_events` SQLite table tracking suggestion shown/accepted/rejected and chat sessions with source attribution. +- **OS-wide shell hooks**: `scripts/shell-hooks/` with preexec/precmd hooks for zsh, bash, fish, PowerShell. Sends every command to daemon via background curl. No delay to prompt. +- **Shell hook daemon endpoints**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to `~/.skill/shell-hooks/` and appends to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check. +- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). +- **`neuroskill brain` new subactions**: `terminal-impact`, `context-cost`, `dev-loops`. +- **`neuroskill activity` new subaction**: `terminal-commands`. +- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons, health indicators (green/yellow/red), recent commands preview, "How it works" documentation. +- **`neuroskill vscode` CLI**: auto-install extension to VS Code, VSCodium, or Cursor on macOS, Linux, and Windows. +- **Meeting nodes in search graph**: meetings appear as amber nodes in the interactive 3D search graph linked by `meeting_prox` edges. + +### i18n + +- Activity dashboard, clipboard, file history, session replay, weekly report, brain status, timeline, and code-brain correlation translations for all 9 locales. + +- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +- Added `umapSettings.backend`, `umapSettings.backendDesc`, `umapSettings.precision`, `umapSettings.precisionDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +- Added `dnd.grayscale` and `dnd.grayscaleDesc` translations to all 9 locales. + +- Search mode labels (Code, Meetings, Brain) translated in all 9 locales. +- Terminal command palette entries translated in all 9 locales. + +- **New `validation` namespace** in all 9 languages (`src/lib/i18n/{de,en,es,fr,he,ja,ko,uk,zh}/validation.ts`): ~95 keys in English (full coverage), ~55 in each other language for the user-visible strings (long-tail descriptions fall back via the runtime `t()` chain). `index.ts` barrel exports updated for every language. + +- **22 new `validation.*` keys per bundle** in all 9 languages (`bundle.l10n.{de,en,es,fr,he,ja,ko,uk,zh-cn}.json`): KSS prompt + 9 score labels with translated Karolinska wording, TLX/PVT prompts, all four escape-hatch labels, the disabled-permanently acknowledgement. +- **Two new `cmd.*` keys** for the validation commands in `package.nls.json`. + +- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +### Docs + +- VS Code extension design plan at `docs/vscode-extension.md`. +- `neuroskill-activity` skill documentation with 18 activity + 24 brain subcommands, terminal integration, shell hook reference, command categorization table. +- Updated `neuroskill-dnd` skill with grayscale mode. +- Updated `neuroskill/README.md` with terminal, brain awareness, and VS Code extension features. +- Updated `skills/SKILL.md` index with terminal tracking skill reference. + +- **VS Code extension README rewritten for skeptical developers**: scientific framing throughout — every derived metric (focus score, flow detector, deep-work minute, struggle predictor, optimal hours, fatigue) now ships with its formula, hyperparameters, and a "what can go wrong" line. Added an explicit *What this is, and isn't* section, a *Validation status* table tracking each metric's evidence level (production / pilot / heuristic / descriptive-only), and a *Validation roadmap* describing the planned KSS / NASA-TLX / PVT / EEG-fatigue calibration channels. Replaced the marketing pain-points table with a per-API signal taxonomy and an architecture paragraph. +- **References section with verified citations**: nine entries with DOIs cross-checked via CrossRef and `doi.org` redirects — Csikszentmihalyi 1990 (flow), Klimesch 1999 (α/θ oscillations), Lubar 1991 (θ/β attention ratio), Barry et al. 2005 (caffeine confound), Newport 2016 (deep work), Roenneberg et al. 2003 (chronotypes), Hart & Staveland 1988 + Hart 2006 (NASA-TLX), Jap et al. 2009 (EEG fatigue index). Each reference linked back to the metric it grounds. +- **Repository metadata pointed at the right repo**: discovered `extensions/vscode/` is a git submodule of `vscode-neuroskill`, not a subdirectory of the monorepo. Reverted `repository.url`, `bugs.url`, `homepage`, `qna` to the submodule's actual repo so the marketplace links resolve. + +### Dependencies + +- **burn-mlx**: added `burn-mlx` from git (`eidola-ai/burn-mlx`, branch `burn-0-20`) as optional dependency in `skill-router` and `skill-daemon`. Workspace `[patch.crates-io]` redirects all `burn-mlx` references to the git version (crates.io 0.1.2 targets burn 0.16; project uses burn 0.20). +- **macOS deployment target**: bumped from 10.15 to 14.0 in `.cargo/config.toml` for Metal 3.0 `simdgroup_matrix` intrinsics required by MLX. Still satisfies llama.cpp's `std::filesystem` (available since 10.15). +- **half 2.4**: added to `skill-router` for GPU f16 precision backend type (`CubeBackend`). + +- **Fix rand 0.7.3 vulnerability**: vendored `phf_generator 0.8.0` with rand bumped from 0.7 to 0.8, eliminating the vulnerable transitive dependency. +- **Remove atty**: migrated `iroh_test_client` from `structopt` (clap v2) to `clap v4` derive, dropping the unmaintained `atty` crate. +- **Update llama-cpp-4 to 0.2.50**: upstream llama.cpp fixes including `common_*` symbol renames. +- **Update kittentts to 0.4.1**: TTS engine update. +- **Add grayscale 0.0.1**: macOS grayscale display control for DND mode. diff --git a/changes/releases/0.0.130-rc.10.md b/changes/releases/0.0.130-rc.10.md new file mode 100644 index 00000000..f0623da0 --- /dev/null +++ b/changes/releases/0.0.130-rc.10.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.10] — 2026-05-02 + +### Features + +- fix windows ci diff --git a/changes/releases/0.0.130-rc.11.md b/changes/releases/0.0.130-rc.11.md new file mode 100644 index 00000000..48183a52 --- /dev/null +++ b/changes/releases/0.0.130-rc.11.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.11] — 2026-05-02 + +### Features + +- 1. GPU f16 SHADER_F16 panic → `catch_unwind` in worker.rs diff --git a/changes/releases/0.0.130-rc.12.md b/changes/releases/0.0.130-rc.12.md new file mode 100644 index 00000000..a251d880 --- /dev/null +++ b/changes/releases/0.0.130-rc.12.md @@ -0,0 +1,9 @@ +## [0.0.130-rc.12] — 2026-05-03 + +### Features + +- translations +- 1. Settings tab font-size lint rule (scripts/check-settings-font-sizes.js) +- The baseline doubles as a punch list — top offenders are ActivityTab (95), ValidationTab (36), and TerminalSessionsCard (27). +- 2. Tray hint when auto-update is OFF and an update is detected +- auto-update + update RC settings diff --git a/changes/releases/0.0.130-rc.13.md b/changes/releases/0.0.130-rc.13.md new file mode 100644 index 00000000..087d190d --- /dev/null +++ b/changes/releases/0.0.130-rc.13.md @@ -0,0 +1,19 @@ +## [0.0.130-rc.13] — 2026-05-04 + +### Features + +- deps(npm): bump the npm-all group with 10 updates +- Bumps the npm-all group with 10 updates: +- | Package | From | To | +- Updates `@tauri-apps/api` from 2.10.1 to 2.11.0 +- Updates `@tauri-apps/plugin-opener` from 2.5.3 to 2.5.4 +- Updates `@threlte/core` from 8.5.9 to 8.5.11 +- Updates `@threlte/extras` from 9.14.9 to 9.15.1 +- Updates `bits-ui` from 2.18.0 to 2.18.1 +- Updates `marked` from 18.0.2 to 18.0.3 +- Updates `@biomejs/biome` from 2.4.13 to 2.4.14 +- Updates `@sveltejs/kit` from 2.58.0 to 2.59.0 +- Updates `@tauri-apps/cli` from 2.10.1 to 2.11.0 +- Updates `svelte-check` from 4.4.6 to 4.4.7 +- --- +- Signed-off-by: dependabot[bot] diff --git a/changes/releases/0.0.130-rc.14.md b/changes/releases/0.0.130-rc.14.md new file mode 100644 index 00000000..a5d3693c --- /dev/null +++ b/changes/releases/0.0.130-rc.14.md @@ -0,0 +1,9 @@ +## [0.0.130-rc.14] — 2026-05-05 + +### Features + +- **ECHT (Endpoint-Corrected Hilbert Transform)**: new EEG metric measuring alpha-band rhythmicity (0–1) via a causal complex-Morlet kernel. Phase estimates remain valid at the buffer edge, where FFT-based Hilbert breaks down. Surfaced in the live dashboard, session detail view, comparison view, recordings (CSV/Parquet), and history aggregates. Reference: Schreglmann et al., *Nat. Commun.* 12:363 (2021), [doi:10.1038/s41467-020-20581-7](https://doi.org/10.1038/s41467-020-20581-7). + +### i18n + +- **ECHT translations**: added `sd.echt`, `compare.echt`, `dashboard.echt`, and `tip.echt` for all 9 supported locales (en, de, es, fr, he, ja, ko, uk, zh). diff --git a/changes/releases/0.0.130-rc.15.md b/changes/releases/0.0.130-rc.15.md new file mode 100644 index 00000000..c9d216f7 --- /dev/null +++ b/changes/releases/0.0.130-rc.15.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.15] — 2026-05-05 + +### Features + +- normalized fonts diff --git a/changes/releases/0.0.130-rc.16.md b/changes/releases/0.0.130-rc.16.md new file mode 100644 index 00000000..546162d3 --- /dev/null +++ b/changes/releases/0.0.130-rc.16.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.16] — 2026-05-05 + +### Features + +- Minor updates and improvements diff --git a/changes/releases/0.0.130-rc.17.md b/changes/releases/0.0.130-rc.17.md new file mode 100644 index 00000000..72bf2627 --- /dev/null +++ b/changes/releases/0.0.130-rc.17.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.17] — 2026-05-05 + +### Features + +- address ZUNA GPU f16 fallback failure and embedding search timestamp mismatch diff --git a/changes/releases/0.0.130-rc.18.md b/changes/releases/0.0.130-rc.18.md new file mode 100644 index 00000000..78b82e98 --- /dev/null +++ b/changes/releases/0.0.130-rc.18.md @@ -0,0 +1,23 @@ +## [0.0.130-rc.18] — 2026-05-06 + +### Performance + +- **Idle re-embed throttle default**: bumped from 10 ms to 200 ms between epochs. The previous value drove the daemon to ~100% CPU on machines without a fast GPU whenever the device was idle. A migration in `load_settings` promotes any existing `idle_reembed_throttle_ms == 10` to 200 and rewrites the file, so users who hit the bug get fixed on next launch. +- **Adaptive scanner cadence**: the auto-started device scanner stays at 5 s while devices are paired or being seen, then backs off to a 30 s tick after 5 minutes of empty scans with no paired devices. Any new discovery (or BLE/USB activity) snaps it back to fast cadence. Previously every transport — USB serial, BLE cache, Cortex, NeuroField, BrainBit, g.tec, ANT Neuro, BrainMaster — was probed forever every 5 s even on installs with no hardware. +- **Active-window poll** slowed from 1 s to 3 s. Still snappy enough for app-switch tracking; ~3× fewer wakeups for the platform window probe (Accessibility on macOS, X11/Wayland calls on Linux). +- **macOS clipboard monitor** now reads everything natively via `objc2-app-kit`: `NSPasteboard.changeCount` for the change gate, `NSPasteboard.types`/`dataForType:` for content classification and size, and `dataForType:NSPasteboardTypePNG` for clipboard image capture. Removes every `osascript` subprocess fork and the Apple Events permission prompt that used to come with it. Steady-state and copy events both run inside the daemon process. + +- **Idle re-embed throttle migration logging**: `load_settings` now emits a `tracing::info!` line when it promotes a legacy `idle_reembed_throttle_ms == 10` to 200, so support can confirm the migration ran from the daemon log without diffing the settings file. + +### UI + +- **Daemon Background Activity panel** in Settings → Settings tab: lists every recurring daemon task with a one-line description, a `Why:` explanation, interval, cost class, and a live "running"/"idle" badge plus `last ran Ns ago · took X ms · N ticks`. Subscribes to the `activity-state` WebSocket event for live updates and falls back to a 30 s safety-net `/v1/activity` poll. Users can decide which trackers to disable based on what each one is actually for and how active it currently is. + +### Server + +- **`GET /v1/activity`**: new endpoint returns a manifest of every recurring background task the daemon runs, with `name`, `does`, `why`, `interval_secs`, `cost`, `user_toggleable`, plus live state (running flag, idle countdown) and `heartbeat` (`last_tick_unix_ms`, `last_duration_ms`, `tick_count`) read from a central registry on `AppState`. So users who notice CPU usage can see — and challenge — exactly which workers are active rather than guessing. +- **Background task registry + `activity-state` event**: `AppState::record_task_heartbeat(id, duration_ms)` is called once per tick by `device-scanner`, `status-monitor`, `idle-reembed`, `active-window-poll`, `clipboard-monitor`, `tty-embedder`, `reconnect`, and `skills-sync`. Each call updates the registry and (time-throttled per task to one broadcast every 5s, so a 100ms loop wouldn't flood the bus) broadcasts an `activity-state` WebSocket event with the heartbeat payload, so connected clients update without polling. Adding a new background loop without registering its id surfaces as a static row with a zeroed heartbeat — a built-in drift signal. The `idle-reembed` heartbeat additionally fires inside the embed-progress event consumer, so the panel reflects real per-batch wall-clock time rather than the outer 10s polling cadence. + +### i18n + +- Translated all `daemonActivity.*` keys (title, intro, loading, running, idle, eventDriven, whyPrefix, costLow/Medium/High, never, lastRanSecondsAgo / MinutesAgo / HoursAgo, tickDuration, tickCount) into all 9 locales: `en`, `de`, `es`, `fr`, `he`, `ja`, `ko`, `uk`, `zh`. Strings are now idiomatic (e.g. ES "carga baja" instead of "coste bajo", JA "実行回数: {n}" instead of "{n} 回実行", FR "il y a {n} s" instead of "{n} ms écoulées") and avoid singular/plural mismatches by using register-neutral phrasings ("Cycles : {n}" rather than "{n} exécutions"). diff --git a/changes/releases/0.0.130-rc.19.md b/changes/releases/0.0.130-rc.19.md new file mode 100644 index 00000000..ca05573e --- /dev/null +++ b/changes/releases/0.0.130-rc.19.md @@ -0,0 +1,6 @@ +## [0.0.130-rc.19] — 2026-05-06 + +### Features + +- cargo deny +- fix tty diff --git a/changes/releases/0.0.130-rc.2.md b/changes/releases/0.0.130-rc.2.md new file mode 100644 index 00000000..79eba5fa --- /dev/null +++ b/changes/releases/0.0.130-rc.2.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.2] — 2026-04-29 + +### Features + +- fix win/linux diff --git a/changes/releases/0.0.130-rc.20.md b/changes/releases/0.0.130-rc.20.md new file mode 100644 index 00000000..c2420ce9 --- /dev/null +++ b/changes/releases/0.0.130-rc.20.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.20] — 2026-05-07 + +### Features + +- fix --deep signature diff --git a/changes/releases/0.0.130-rc.21.md b/changes/releases/0.0.130-rc.21.md new file mode 100644 index 00000000..d4c496f4 --- /dev/null +++ b/changes/releases/0.0.130-rc.21.md @@ -0,0 +1,19 @@ +## [0.0.130-rc.21] — 2026-05-08 + +### Features + +- deps(npm): bump the npm-all group with 10 updates (#60) +- Bumps the npm-all group with 10 updates: +- | Package | From | To | +- Updates `@tauri-apps/api` from 2.10.1 to 2.11.0 +- Updates `@tauri-apps/plugin-opener` from 2.5.3 to 2.5.4 +- Updates `@threlte/core` from 8.5.9 to 8.5.11 +- Updates `@threlte/extras` from 9.14.9 to 9.15.1 +- Updates `bits-ui` from 2.18.0 to 2.18.1 +- Updates `marked` from 18.0.2 to 18.0.3 +- Updates `@biomejs/biome` from 2.4.13 to 2.4.14 +- Updates `@sveltejs/kit` from 2.58.0 to 2.59.0 +- Updates `@tauri-apps/cli` from 2.10.1 to 2.11.0 +- Updates `svelte-check` from 4.4.6 to 4.4.7 +- --- +- Signed-off-by: dependabot[bot] diff --git a/changes/releases/0.0.130-rc.22.md b/changes/releases/0.0.130-rc.22.md new file mode 100644 index 00000000..19afc15a --- /dev/null +++ b/changes/releases/0.0.130-rc.22.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.22] — 2026-05-08 + +### Features + +- updated iroh diff --git a/changes/releases/0.0.130-rc.23.md b/changes/releases/0.0.130-rc.23.md new file mode 100644 index 00000000..e5b69124 --- /dev/null +++ b/changes/releases/0.0.130-rc.23.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.23] — 2026-05-09 + +### Features + +- upgraded llama.cpp and added mtp diff --git a/changes/releases/0.0.130-rc.24.md b/changes/releases/0.0.130-rc.24.md new file mode 100644 index 00000000..ac0a117f --- /dev/null +++ b/changes/releases/0.0.130-rc.24.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.24] — 2026-05-09 + +### Features + +- fixed biome diff --git a/changes/releases/0.0.130-rc.25.md b/changes/releases/0.0.130-rc.25.md new file mode 100644 index 00000000..64da7948 --- /dev/null +++ b/changes/releases/0.0.130-rc.25.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.25] — 2026-05-10 + +### Features + +- fixed sscache + cmake diff --git a/changes/releases/0.0.130-rc.26.md b/changes/releases/0.0.130-rc.26.md new file mode 100644 index 00000000..c3c89138 --- /dev/null +++ b/changes/releases/0.0.130-rc.26.md @@ -0,0 +1,21 @@ +## [0.0.130-rc.26] — 2026-05-16 + +### Performance + +- **Local-only `hotpath` profiling for skill-router**: added optional `hotpath = "0.16"` dep behind three opt-in features (`hotpath`, `hotpath-cpu`, `hotpath-alloc`) so the default build pulls nothing extra. Annotated the suspected UMAP hot paths (`load_embeddings_range`, `load_labels_range`, `analyze_umap_points`, `fit_umap_gpu`, `fit_umap_mlx`, `umap_compute_inner`) with `#[cfg_attr(feature = "hotpath", hotpath::measure)]` — zero-cost when the feature is off. New `crates/skill-router/examples/umap_hotpath.rs` runner uses `#[hotpath::main]` to seed 500+500 synthetic 32-dim embeddings and print a per-function timing table on exit. Run with `cargo run -p skill-router --release --example umap_hotpath --features='gpu,hotpath'` (add `hotpath-alloc` for allocation tracking; swap `gpu` for `mlx` on Apple). First run on Apple M4 Pro confirmed 99.37% of UMAP wall-clock is inside `fit_umap_gpu` (44.96s of 45.24s on 1000×32-dim points); I/O and post-processing are five orders of magnitude smaller — useful signal for where *not* to optimize. + +### Bugfixes + +- **Fix daemon WS command dispatch starvation under event load**: the `/v1/events` handler ran `socket.send` (broadcast events) and `socket.recv` (incoming commands) in the same `tokio::select!` arm-set. With a Muse @ 256 Hz producing ~300–500 frames/sec across `EegSample`/`EegBands`/`ImuSample`/`PpgSample`/`SignalQuality`, the event arm won repeatedly, `sender.send().await` kept the task busy filling the kernel TCP buffer, and incoming commands timed out client-side at 15s. The smoke test hit this on every WS command. Restructured `handle_ws`: split the socket into `(sender, receiver)` halves; sender task drains a two-channel priority queue (responses via `biased` select, then events) with per-command dispatch spawned so slow handlers (`umap`, `sessions`) can't block the loop. High-rate event types are gated behind a per-connection subscribed set (default: none) — clients opt in via `{command:"subscribe",events:["EegSample",...]}` or `events:["*"]`; the neuroskill UI (`src/lib/daemon/ws.ts`) and neuroloop CLI auto-subscribe to the types they consume. Two regression tests (`ws_command_responds_under_event_flood`, `ws_filters_high_rate_events_by_default`) lock in the priority-queue invariant + default-filter behavior. + +- **Fix `interactive_search` hang on empty query**: with `query=""` the SQL `text LIKE '%' || '' || '%'` matched every label in `labels.sqlite`, then looped `search_embeddings_in_range(±10 minutes)` across every daily DB — 30s+ before the test harness gave up. The daemon now short-circuits to `{"ok":false,"error":"empty query"}` when the query is empty or whitespace-only. + +- **Fix smoke-test port discovery latching onto VS Code / dev-tool ports**: `test.ts`'s mDNS-fallback used `pgrep -if 'skill'`, which matched any process whose command line contained the substring "skill" — including VS Code Helpers running in `/Users/Shared/skill/...` workspaces. Once a wrong port was picked, `testWs()`'s bare-WebSocket handshake accepted it (VS Code, Vite HMR, etc. all accept WS upgrades), and every command then timed out at 15s, burning the entire 180s smoke budget. Tightened the pgrep regex to `(^|/)skill-daemon($|\s)|target/(debug|release)/skill($|\s)`, swapped `testWs()` to use the daemon's `DaemonStarted` welcome envelope as a protocol discriminator, and moved auth-token loading ahead of discovery so the probe can authenticate. + +### LLM + +- **MTP (Multi-Token Prediction) speculative decoding**: wired the upstream MTP API from `llama-cpp-4` 0.2.56 into the text-only generation path. When the active model is catalog-flagged `mtp: true` (e.g. the `froggeric/Qwen3.6-27B-MTP-GGUF` family) and the user sets `mtp_draft_count > 0`, the actor builds the target context with `with_n_rs_seq` so partial KV rollback works on hybrid/recurrent models (Qwen3.6 M-RoPE), runs a one-shot draft-context smoke check at load time (downgrades to the standard path on failure), and per-request constructs a `LlamaContextType::Mtp` draft context plus `MtpSession` to drive the full `draft → verify-batch → match-prefix → KV-rollback → accept` loop. Acceptance rate is logged per request. Vision (mtmd) requests stay on the non-MTP path. Verified end-to-end against `Qwen3.6-27B-IQ2_M-mtp.gguf` on Apple M4 Pro (3/3 drafts accepted on a short greedy prompt) via the new `tests/llm_mtp_e2e.rs` integration test, which skips gracefully when no MTP-capable GGUF is cached. + +### Dependencies + +- **Update llama-cpp-4 to 0.2.56**: bumped `llama-cpp-4` and `llama-cpp-sys-4` from 0.2.54 to 0.2.56, picking up upstream llama.cpp `64b38b561` (May 2026) which now ships MTP support natively (PR ggml-org/llama.cpp#22673). Breaking changes in the fork: the in-tree MTP patch is gone, so the `mtp` Cargo feature, `LlamaContext::set_mtp`, and `LlamaModelParams::with_override_arch` no longer exist. Dropped `"mtp"` from the metal/vulkan dependency feature lists in `skill-llm/Cargo.toml` and removed the dangling `llm-mtp` workspace feature (no downstream consumer). The new upstream API (`LlamaContextType::Mtp`, `with_ctx_type`, `with_n_rs_seq`, `llama_cpp_4::mtp::MtpSession`) is wired separately in the MTP speculative-decoding feature. diff --git a/changes/releases/0.0.130-rc.27.md b/changes/releases/0.0.130-rc.27.md new file mode 100644 index 00000000..dc39fe10 --- /dev/null +++ b/changes/releases/0.0.130-rc.27.md @@ -0,0 +1,9 @@ +## [0.0.130-rc.27] — 2026-05-17 + +### Build + +- **Fix release retry: cargo failures inside `run_cmd` were silently ignored**: `release-mac.yml`, `release-linux.yml`, and `release-windows.yml` call `run_cmd` via `if ! run_cmd; then`, which inhibits `set -e` inside the function body. A failing `cargo build -p skill-daemon` (e.g. link error against stale prebuilt llama libs) would silently continue to the next `cargo build`, the function would return 0, and the prebuilt→source-build fallback would never fire — leaving the assemble/package step to fail later with a confusing "missing daemon binary" error. Added explicit `|| return $?` after each cargo invocation so failures propagate regardless of bash's `set -e` inhibition rules. + +### Dependencies + +- **Bump llama-cpp-4 to 0.2.57**: bumped `llama-cpp-4` and `llama-cpp-sys-4` from 0.2.56 to 0.2.57, picking up the Windows MSVC bindgen fix (the `LLAMA_CONTEXT_TYPE_*` constants are `i32` on MSVC but the `LlamaContextType` enum is `#[repr(u32)]`, which broke the Windows release build). Pinned `LLAMA_PREBUILT_TAG` in `scripts/ci.mjs` to `v0.2.57` so the prebuilt llama libs ship the same MTP symbols (`mtp_session_new`, `mtp_session_draft`, etc.) the crate now expects — the previous `0.2.46` pin caused undefined-symbol link failures for `skill-daemon` on macOS and Linux after the 0.2.56 MTP upgrade. diff --git a/changes/releases/0.0.130-rc.28.md b/changes/releases/0.0.130-rc.28.md new file mode 100644 index 00000000..616bac90 --- /dev/null +++ b/changes/releases/0.0.130-rc.28.md @@ -0,0 +1,11 @@ +## [0.0.130-rc.28] — 2026-05-19 + +### Features + +- **Label index benchmark validation**: add side-by-side HNSW and TurboQuant label search benchmarks with top-result agreement, top-k overlap, and cosine-distance delta checks so users can verify result proximity before switching backends. + +- **TurboQuant label index backend**: add TurboQuant as an alternative label search backend alongside HNSW, while keeping HNSW as the default and maintaining both indexes during rebuilds and incremental label inserts. + +### UI + +- **Configurable label search backend**: add Settings controls for choosing between HNSW and TurboQuant, showing index counts, rebuilding indexes, and persisting the selected backend. diff --git a/changes/releases/0.0.130-rc.29.md b/changes/releases/0.0.130-rc.29.md new file mode 100644 index 00000000..a9ffa611 --- /dev/null +++ b/changes/releases/0.0.130-rc.29.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.29] — 2026-05-19 + +### Features + +- Minor updates and improvements diff --git a/changes/releases/0.0.130-rc.3.md b/changes/releases/0.0.130-rc.3.md new file mode 100644 index 00000000..c2f9fb6b --- /dev/null +++ b/changes/releases/0.0.130-rc.3.md @@ -0,0 +1,6 @@ +## [0.0.130-rc.3] — 2026-04-29 + +### Features + +- fixed issues with the metrics, keychain access (3 -> 1 + on demand), windows CI +- umap e2e diff --git a/changes/releases/0.0.130-rc.30.md b/changes/releases/0.0.130-rc.30.md new file mode 100644 index 00000000..c35e83a0 --- /dev/null +++ b/changes/releases/0.0.130-rc.30.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.30] — 2026-05-19 + +### Features + +- turboquant index diff --git a/changes/releases/0.0.130-rc.31.md b/changes/releases/0.0.130-rc.31.md new file mode 100644 index 00000000..27f5f291 --- /dev/null +++ b/changes/releases/0.0.130-rc.31.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.31] — 2026-05-20 + +### Features + +- llama-cpp-rs@0.3.0 diff --git a/changes/releases/0.0.130-rc.4.md b/changes/releases/0.0.130-rc.4.md new file mode 100644 index 00000000..5e46fef9 --- /dev/null +++ b/changes/releases/0.0.130-rc.4.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.4] — 2026-04-29 + +### Features + +- Minor updates and improvements diff --git a/changes/releases/0.0.130-rc.5.md b/changes/releases/0.0.130-rc.5.md new file mode 100644 index 00000000..b092198b --- /dev/null +++ b/changes/releases/0.0.130-rc.5.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.5] — 2026-04-30 + +### Features + +- fix vulkan cache on windows ci diff --git a/changes/releases/0.0.130-rc.6.md b/changes/releases/0.0.130-rc.6.md new file mode 100644 index 00000000..9769dc36 --- /dev/null +++ b/changes/releases/0.0.130-rc.6.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.6] — 2026-04-30 + +### Features + +- Minor updates and improvements diff --git a/changes/releases/0.0.130-rc.7.md b/changes/releases/0.0.130-rc.7.md new file mode 100644 index 00000000..9b8e30de --- /dev/null +++ b/changes/releases/0.0.130-rc.7.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.7] — 2026-05-02 + +### Features + +- Replace per-app AppleScript window tracking (which triggered a macOS TCC Automation dialog for every new foreground app) with native Accessibility API (AXUIElement) + CoreGraphics calls that require only a single one-time "Accessibility" permission for NeuroSkill. diff --git a/changes/releases/0.0.130-rc.8.md b/changes/releases/0.0.130-rc.8.md new file mode 100644 index 00000000..710ed735 --- /dev/null +++ b/changes/releases/0.0.130-rc.8.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.8] — 2026-05-02 + +### Features + +- Minor updates and improvements diff --git a/changes/releases/0.0.130-rc.9.md b/changes/releases/0.0.130-rc.9.md new file mode 100644 index 00000000..2e6109e1 --- /dev/null +++ b/changes/releases/0.0.130-rc.9.md @@ -0,0 +1,5 @@ +## [0.0.130-rc.9] — 2026-05-02 + +### Features + +- Minor updates and improvements diff --git a/changes/releases/0.0.131-rc.2.md b/changes/releases/0.0.131-rc.2.md new file mode 100644 index 00000000..e79027df --- /dev/null +++ b/changes/releases/0.0.131-rc.2.md @@ -0,0 +1,5 @@ +## [0.0.131-rc.2] — 2026-05-31 + +### Features + +- Minor updates and improvements diff --git a/changes/releases/0.0.131-rc.3.md b/changes/releases/0.0.131-rc.3.md new file mode 100644 index 00000000..b4b32a4d --- /dev/null +++ b/changes/releases/0.0.131-rc.3.md @@ -0,0 +1,5 @@ +## [0.0.131-rc.3] — 2026-06-01 + +### Features + +- fix release ci diff --git a/changes/releases/0.0.131-rc.4.md b/changes/releases/0.0.131-rc.4.md new file mode 100644 index 00000000..153365d5 --- /dev/null +++ b/changes/releases/0.0.131-rc.4.md @@ -0,0 +1,5 @@ +## [0.0.131-rc.4] — 2026-06-01 + +### Features + +- fixed windows and linux builds diff --git a/changes/releases/0.0.131-rc.5.md b/changes/releases/0.0.131-rc.5.md new file mode 100644 index 00000000..8131c440 --- /dev/null +++ b/changes/releases/0.0.131-rc.5.md @@ -0,0 +1,6 @@ +## [0.0.131-rc.5] — 2026-06-10 + +### Features + +- upgraded to rlx 0.2.5 +- upgrade to rlx 0.2.4 diff --git a/changes/releases/0.0.131-rc.6.md b/changes/releases/0.0.131-rc.6.md new file mode 100644 index 00000000..429823e5 --- /dev/null +++ b/changes/releases/0.0.131-rc.6.md @@ -0,0 +1,5 @@ +## [0.0.131-rc.6] — 2026-06-10 + +### Features + +- Minor updates and improvements diff --git a/changes/unreleased.md b/changes/unreleased.md index 2f16bedc..9a0701ef 100644 --- a/changes/unreleased.md +++ b/changes/unreleased.md @@ -3,3 +3,16 @@ ### UI - Add "Force Restart" button to the engine status hover panel on the dashboard + +### Build + +- Fix Linux CI `cargo test` OpenBLAS loader errors: shared `build-support/linux_openblas.rs` emits link-search + rpath for system BLAS; drop ONNX Runtime setup from CI (RLX path only). +- Fix Windows release compile failure: move OpenBLAS build helper out of `build/` so SvelteKit frontend output does not clobber it before `cargo build`. +- Fix Linux CI link failure for `skill-label-index` tests: link system OpenBLAS when `turboquant-index` is enabled (undefined `cblas_*gemm` symbols). +- Make `turboquant-index` a compile-time optional feature (HNSW-only builds skip OpenBLAS); enable it from daemon/Tauri. Skip TurboVec index maintenance while the preferred backend is HNSW and no TurboVec files exist on disk. + +- Align `skill-headless` to `wry 0.54` / `tao 0.34` so it matches the versions `tauri-runtime-wry 2.10.1` already pulls in. Previously the workspace built two copies of wry/tao (0.54.4 + 0.55.0, 0.34.8 + 0.35.0) because `skill-headless` pinned the newer pair. Single resolved version now, smaller binary, no functional change. + +### Security + +- **Lazy keychain access**: the macOS keychain is no longer read at app/daemon startup. Previously, `load_settings()` eagerly fetched all eight stored secrets (api_token, Emotiv, IDUN, Oura, Neurosity), and three separate processes (Tauri shell, daemon `state::new`, daemon `main`) each ran it during boot. On a fresh build the code signature changes, so the OS prompted up to three times before the user could see the app. Secrets are now fetched on demand from the keychain only when the user actually opens device settings, connects a device, or runs a sync — so at most one prompt appears, gated on user intent. Tauri's `AppState` no longer caches `api_token` / `device_api_config`; the daemon's route handlers (`set_device_api_config`, `set_api_token`) write secrets directly to the keychain and skip empty values to avoid clobbering existing entries on partial saves. diff --git a/changes/unreleased/01-human-vs-ai-tracking.md b/changes/unreleased/01-human-vs-ai-tracking.md deleted file mode 100644 index 6c856913..00000000 --- a/changes/unreleased/01-human-vs-ai-tracking.md +++ /dev/null @@ -1,43 +0,0 @@ -### Features - -- **Human vs AI activity tracking**: every edit and commit is now classified as `source: "human"` or `source: "ai"` in real-time. `AIActivityTracker` watches VSCode commands (Copilot/Codeium completions, inline chat, AI-generated commit messages) to tag subsequent edits/commits, exposes `getAIRatioForFile()` (rolling 5-minute ratio) for CodeLens + sidebar, and forwards the `source` field to the daemon for `build_events` / `ai_events` storage. - -## How it works - -The `AIActivityTracker` monitors VSCode command execution to detect AI tool usage: - -- **Inline completions** (Copilot, Codeium) — edits within 5 seconds after an AI command are tagged `source: "ai"` -- **Inline chat** (`inlineChat.start`, `copilot.interactiveEditor.*`) — subsequent edits in the same file are tagged AI -- **Commit messages** — `github.copilot.git.generateCommitMessage` marks the next commit as AI-assisted -- **Everything else** — classified as `source: "human"` - -## What's tracked - -| Signal | Classification | -|--------|---------------| -| Manual typing | `human` | -| Copilot inline suggestion accepted | `ai` | -| Copilot inline chat edits | `ai` | -| Paste from external source | `human` | -| AI-generated commit message | `ai` | -| Manually typed commit message | `human` | - -## Per-file AI ratio - -`AIActivityTracker.getAIRatioForFile(path)` returns a rolling 5-minute ratio (0.0 = all human, 1.0 = all AI) used by: -- CodeLens annotations (shows "AI-Assisted" vs focus score) -- Sidebar (Human/AI percentage display) -- Brain status command (Human/AI split) - -## Daemon integration - -The `source` field is sent on every `edit` and `git_commit` event to the daemon. The daemon stores: -- AI commits as `"git commit (ai-assisted)"` in `build_events` -- AI commits also as `ai_events` for analytics weighting -- Completion acceptances as `ai_events` with type `"suggestion_accepted"` - -## Files - -- `src/ai-tracker.ts` — Core tracker (new) -- `src/events.ts` — Wired to classify edits and commits -- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Daemon-side storage diff --git a/changes/unreleased/02-focus-code-review.md b/changes/unreleased/02-focus-code-review.md deleted file mode 100644 index eafe1068..00000000 --- a/changes/unreleased/02-focus-code-review.md +++ /dev/null @@ -1,31 +0,0 @@ -### Features - -- **Focus-aware code review CodeLens**: annotations at the top of each file show the developer's focus level when the file was last edited (`⚠ Low Focus (42)`, `ℹ Focus: 65/100`, `🤖 AI-Assisted (85%)`, or none for high focus). `NeuroSkill: Show Files Needing Review` command lists low-focus human-authored files. Toggle via `neuroskill.focusCodeLens`. - -## What you see - -- `⚠ Low Focus (42) — Review Recommended` — File was edited during low focus. Click to see all files needing review. -- `ℹ Focus: 65/100` — Moderate focus, informational only. -- `🤖 AI-Assisted (85%)` — Most edits were AI-generated, focus score not applicable. -- No annotation — High focus (>70) or no data yet. - -## Commands - -**NeuroSkill: Show Files Needing Review** (`Cmd+Shift+P`) -- Shows a QuickPick list of files edited during low focus (<50) that were mostly human-authored -- Sorted by focus score (lowest first) -- Select a file to open it - -## How it works - -- `FocusCodeLensProvider` queries `/brain/cognitive-load` (grouped by file) every 30 seconds -- Combines focus data with `AIActivityTracker.getAIRatioForFile()` to distinguish human vs AI code -- Files with high AI ratio (>70%) show AI label instead of focus score — AI code doesn't reflect human cognitive state - -## Settings - -`neuroskill.focusCodeLens` (default: `true`) — Toggle CodeLens annotations on/off. - -## Files - -- `src/codelens-provider.ts` — CodeLens provider (new) diff --git a/changes/unreleased/03-flow-shield.md b/changes/unreleased/03-flow-shield.md deleted file mode 100644 index 54b5c842..00000000 --- a/changes/unreleased/03-flow-shield.md +++ /dev/null @@ -1,28 +0,0 @@ -### Features - -- **Smart Interruption Shield**: when `/brain/flow-state` reports `in_flow: true`, VS Code's Do Not Disturb mode auto-enables and the status bar shows `$(shield) In Flow 12m`. `NeuroSkill: Toggle Flow Shield` cycles Auto / Forced-on / Forced-off. Toggle via `neuroskill.flowShield`. - -## How it works - -- When `/brain/flow-state` reports `in_flow: true`, the Flow Shield activates -- Enables VSCode's Do Not Disturb mode (VSCode 1.90+) -- Shows `$(shield) In Flow 12m` in the status bar with elapsed time -- When flow state ends, DND is automatically disabled - -## Manual override - -**NeuroSkill: Toggle Flow Shield** (`Cmd+Shift+P`) - -Cycles through three modes: -1. **Auto** (default) — activates/deactivates based on EEG flow detection -2. **Forced on** — always active regardless of flow state -3. **Forced off** — never active - -## Settings - -`neuroskill.flowShield` (default: `true`) — Enable/disable the flow shield feature. - -## Files - -- `src/flow-shield.ts` — Flow shield implementation (new) -- `src/brain.ts` — Calls `flowShield.update()` every 30s diff --git a/changes/unreleased/04-adaptive-break-coach.md b/changes/unreleased/04-adaptive-break-coach.md deleted file mode 100644 index bda490e5..00000000 --- a/changes/unreleased/04-adaptive-break-coach.md +++ /dev/null @@ -1,33 +0,0 @@ -### Features - -- **Adaptive Break Coach**: personalized break timing based on the developer's actual EEG focus cycle (via `/brain/break-timing`), not generic Pomodoro. Status bar countdown `$(clock) Break in 8m`, max one notification per cycle, auto-resets when fatigue indicates idleness. `NeuroSkill: Take a Break` resets the timer manually. Toggle via `neuroskill.breakCoach`. - -## How it works - -- Queries `/brain/break-timing` to learn the developer's natural focus cycle length -- Shows a countdown in the status bar: `$(clock) Break in 8m` -- When the predicted focus drop is imminent (<5 min), the countdown turns visible -- When the cycle ends, shows `$(clock) Break time` and optionally notifies - -## Notifications - -- Max one notification per focus cycle -- Message: "You've been focused for 47m. Your natural cycle is 42m — take a break?" -- Buttons: "Take Break" (resets timer) or "Dismiss" - -## Timer sync - -The break coach automatically syncs with the daemon's fatigue data. If `continuous_work_mins` drops below 5 (indicating the user was idle), the session timer resets — no false break suggestions after returning from lunch. - -## Commands - -**NeuroSkill: Take a Break** (`Cmd+Shift+P`) — Manually acknowledge a break and reset the timer. - -## Settings - -`neuroskill.breakCoach` (default: `true`) — Enable/disable break coaching. - -## Files - -- `src/break-coach.ts` — Break coach implementation (new) -- `src/brain.ts` — Calls `breakCoach.refresh()` and `resetSessionIfIdle()` every 30s diff --git a/changes/unreleased/05-struggle-ai-bridge.md b/changes/unreleased/05-struggle-ai-bridge.md deleted file mode 100644 index 17979881..00000000 --- a/changes/unreleased/05-struggle-ai-bridge.md +++ /dev/null @@ -1,31 +0,0 @@ -### Features - -- **Struggle → AI assist bridge**: when `/brain/struggle-predict` flags a file (EEG focus + undo rate + velocity drop + time-on-file), shows an actionable notification with **Open Copilot Chat**, **Open Terminal**, and **Step Back** buttons. Debounced to one suggestion per file per 10 minutes. Toggle via `neuroskill.struggleBridge`. - -## How it works - -- Monitors `/brain/struggle-predict` (EEG focus + undo rate + velocity drop + time-on-file) -- When `struggling: true`, shows an actionable notification: - > "Stuck on auth.ts? (score: 78) Consider breaking the problem into smaller pieces." - -## Action buttons - -| Button | Action | -|--------|--------| -| **Open Copilot Chat** | Opens GitHub Copilot interactive chat (or generic chat panel) | -| **Open Terminal** | Toggles terminal for CLI debugging | -| **Step Back** | Dismiss and take a mental break | - -## Debouncing - -- Max one suggestion per file per 10 minutes -- Prevents notification fatigue while still catching genuine struggles - -## Settings - -`neuroskill.struggleBridge` (default: `true`) — Enable/disable struggle detection and AI suggestions. - -## Files - -- `src/struggle-bridge.ts` — Struggle bridge implementation (new) -- `src/brain.ts` — Calls `struggleBridge.check()` every 30s (replaces the old generic struggle notification) diff --git a/changes/unreleased/06-flow-triggers-dashboard.md b/changes/unreleased/06-flow-triggers-dashboard.md deleted file mode 100644 index d31f425d..00000000 --- a/changes/unreleased/06-flow-triggers-dashboard.md +++ /dev/null @@ -1,29 +0,0 @@ -### Features - -- **Personal Flow Triggers dashboard**: collapsible "Your Flow Recipe" sidebar section mining 7-day EEG + activity history — best language (`/brain/code-eeg`), peak hours (`/brain/optimal-hours`), natural cycle length (`/brain/break-timing`), top flow killer (`/brain/context-cost`). Toggle via `neuroskill.flowTriggers`. - -## What you see - -In the NeuroSkill sidebar panel, a collapsible "Your Flow Recipe" section shows: - -- **Best language** — "Focus best on Rust (82)" — from `/brain/code-eeg` -- **Peak hours** — "Peak hours: 9:00, 10:00, 14:00" — from `/brain/optimal-hours` -- **Natural cycle** — "Natural cycle: 42m" — from `/brain/break-timing` -- **Flow killer** — "Flow killer: Slack (focus 38 at switch)" — from `/brain/context-cost` - -## Data sources - -| Insight | API Endpoint | Time Range | -|---------|-------------|------------| -| Best languages | `/brain/code-eeg` | Last 7 days | -| Peak hours | `/brain/optimal-hours` | Last 7 days | -| Natural cycle | `/brain/break-timing` | Last 7 days | -| Flow killers | `/brain/context-cost` | Last 7 days | - -## Settings - -`neuroskill.flowTriggers` (default: `true`) — Show/hide the flow triggers section in the sidebar. - -## Files - -- `src/sidebar.ts` — `_fetchFlowTriggers()` and `_renderFlowTriggers()` methods diff --git a/changes/unreleased/07-focus-scored-commits.md b/changes/unreleased/07-focus-scored-commits.md deleted file mode 100644 index bb49b4e1..00000000 --- a/changes/unreleased/07-focus-scored-commits.md +++ /dev/null @@ -1,38 +0,0 @@ -### Features - -- **Focus-scored git commits in sidebar**: collapsible "Recent Commits" section shows the last 8 commits with author marker (👤 human / 🤖 AI) and a color-coded focus badge captured at commit time via `/brain/flow-state`. AI-assisted commits show "AI" instead of a focus score. Toggle via `neuroskill.focusCommits`. - -## What you see - -In the NeuroSkill sidebar, a collapsible "Recent Commits" section shows the last 8 commits: - -``` -👤 82 fix: resolve auth race condition -👤 45 chore: update dependencies -🤖 AI refactor: extract helper functions -👤 71 feat: add user preferences -``` - -- **👤** = human-authored commit -- **🤖** = AI-assisted commit (message generated by Copilot) -- **Focus badge** = color-coded: green (>70), yellow (40-70), red (<40) -- AI commits show "AI" instead of a focus score — AI output doesn't measure human cognition - -## How it works - -- When the extension detects a git commit (SCM input box clears), it: - 1. Snapshots current EEG focus via `/brain/flow-state` - 2. Checks `AIActivityTracker.isCommitAIAssisted()` - 3. Records the commit with focus score + source label -- Commits stored in-memory (last 15), refreshed on sidebar render -- The daemon also stores commits with human/AI distinction in `build_events` - -## Settings - -`neuroskill.focusCommits` (default: `true`) — Show/hide the commits section in the sidebar. - -## Files - -- `src/sidebar.ts` — `recordCommit()`, `_renderCommits()` -- `src/extension.ts` — Wires commit detection to sidebar recording -- `src/events.ts` — `onCommit` callback with human/AI source diff --git a/changes/unreleased/08-optimal-task-router.md b/changes/unreleased/08-optimal-task-router.md deleted file mode 100644 index 982c1ff9..00000000 --- a/changes/unreleased/08-optimal-task-router.md +++ /dev/null @@ -1,29 +0,0 @@ -### Features - -- **Optimal Task Router**: monitors flow score every 30 s and, when it changes by >20 points, suggests an appropriate task type (refactoring/new features at high focus, code review at moderate, docs/routine at low). Debounced to one suggestion per 15 minutes. Toggle via `neuroskill.taskRouter`. - -## How it works - -- Monitors the flow state score every 30 seconds -- When focus changes by >20 points from the last reading, suggests an appropriate task type: - -| Focus Level | Suggestion | -|------------|------------| -| >75 | "Focus is high (85) — great time for complex work like refactoring or new features." | -| 45-75 | "Focus moderate (58) — good for code review, testing, or incremental tasks." | -| <45 | "Focus low (32) — consider documentation, routine tasks, or a break." | - -## Debouncing - -- Maximum one suggestion every 15 minutes -- No suggestion on the first reading (establishes baseline) -- No suggestion if focus stays within 20 points of the last reading - -## Settings - -`neuroskill.taskRouter` (default: `true`) — Enable/disable task routing suggestions. - -## Files - -- `src/task-router.ts` — Task router implementation (new) -- `src/brain.ts` — Calls `taskRouter.check()` every 30s diff --git a/changes/unreleased/09-eeg-heatmap.md b/changes/unreleased/09-eeg-heatmap.md deleted file mode 100644 index 297546f9..00000000 --- a/changes/unreleased/09-eeg-heatmap.md +++ /dev/null @@ -1,29 +0,0 @@ -### UI - -- **EEG focus timeline heatmap**: collapsible "Focus Timeline" sidebar section renders a ~280×36 px SVG sparkline of today's EEG focus (`/brain/eeg-range`, max 120 points), color-graded green/yellow/red, with hour labels and file-name annotations at peaks/valleys (merged from `/activity/timeline`). Toggle via `neuroskill.eegHeatmap`. - -## What you see - -In the NeuroSkill sidebar, a collapsible "Focus Timeline" section shows: - -- A ~280px wide, ~36px tall SVG sparkline -- Color gradient: green (>70 focus), yellow (40-70), red (<40) -- Hour labels along the bottom (0:00, 3:00, 6:00, ...) -- File names annotated at focus peaks and valleys - -## Data sources - -| Data | API Endpoint | -|------|-------------| -| EEG time-series | `/brain/eeg-range` (today, max 120 points) | -| File context | `/activity/timeline` (today, last 200 events) | - -The heatmap merges EEG data points with the closest timeline events to show which files correspond to focus peaks and dips. - -## Settings - -`neuroskill.eegHeatmap` (default: `true`) — Show/hide the heatmap in the sidebar. - -## Files - -- `src/sidebar.ts` — `_fetchHeatmap()` and `_renderHeatmap()` methods diff --git a/changes/unreleased/10-daemon-event-storage.md b/changes/unreleased/10-daemon-event-storage.md deleted file mode 100644 index 21df0863..00000000 --- a/changes/unreleased/10-daemon-event-storage.md +++ /dev/null @@ -1,16 +0,0 @@ -### Bugfixes - -- **Daemon-side event storage**: `git_commit`/`push`/`pull`/`checkout`/`stage`/`unstage`/`stash` events were received but never written to `build_events` (used only for EEG labels). Now persisted, with AI-assisted commits also recorded as `ai_events` (type `"ai_commit"`) and labelled `"git commit (AI)"`. -- **Completion-accepted events were dropped**: previously ignored by the daemon. Now stored as `ai_events` (type `"suggestion_accepted"`) and as edit chunks so they show up in code metrics and AI usage analytics. - -## Impact on analysis - -Brain analysis endpoints can now: -- Count human vs AI commits (`/brain/developer-insights`) -- Track AI suggestion acceptance rates (`/brain/ai-usage`) -- Include git activity in the activity timeline -- Weight human-authored code differently from AI output in focus/productivity scores - -## Files - -- `crates/skill-daemon/src/routes/settings_hooks_activity.rs` — Event handler updates diff --git a/changes/unreleased/11-shared-daemon-client.md b/changes/unreleased/11-shared-daemon-client.md deleted file mode 100644 index 230205fb..00000000 --- a/changes/unreleased/11-shared-daemon-client.md +++ /dev/null @@ -1,32 +0,0 @@ -### Refactor - -- **Shared `DaemonClient` for VS Code extension**: extracted the repeated `fetch` + auth-token + port-discovery + 3 s timeout pattern into one class. All features now use `client.post(path, body)`. Returns `null` on failure (never throws), with `setToken()` for refresh on reconnect. - -## Before - -Every component (brain.ts, sidebar.ts, extension.ts) independently constructed fetch calls: -```typescript -const port = await discoverDaemonPort(config); -const base = `http://${config.daemonHost}:${port}/v1`; -const headers = { "Content-Type": "application/json" }; -if (token) headers["Authorization"] = `Bearer ${token}`; -const resp = await fetch(`${base}${path}`, { method: "POST", headers, body, signal: AbortSignal.timeout(3000) }); -``` - -## After - -```typescript -const client = new DaemonClient(config, token); -const result = await client.post("/brain/flow-state", { windowSecs: 300 }); -``` - -## Benefits - -- Single place to update auth, timeout, port discovery -- All 8 new features use the shared client -- `setToken()` method for token refresh on reconnect -- Returns `null` on any failure (never throws) — all features handle gracefully - -## Files - -- `src/daemon-client.ts` — DaemonClient class (new) diff --git a/changes/unreleased/feat-activity-dashboard.md b/changes/unreleased/feat-activity-dashboard.md deleted file mode 100644 index 40f22654..00000000 --- a/changes/unreleased/feat-activity-dashboard.md +++ /dev/null @@ -1,19 +0,0 @@ -### Features - -- **Activity dashboard tab**: new Settings tab with daily summary, productivity score (0-100 composite), hourly heatmap, top files/projects, language breakdown, focus sessions, meetings, and stale file alerts. -- **Focus session replay**: click any session to expand a detailed view with file timeline, edit counts, EEG focus overlay bar, and meeting interruptions. -- **Weekly report with CSV export**: 7-day activity digest with daily breakdown chart and one-click CSV download. -- **Productivity scoring**: composite score from edit velocity + deep work time + context stability + EEG focus. -- **Stale file detection**: files edited but untouched for 7+ days. -- **Code-Brain correlation**: focus ranked by language, project, and individual files — shows which code drains or energizes you. -- **Activity timeline**: unified chronological feed of all events (files, builds, meetings, AI, clipboard) with EEG focus indicators. - -### UI - -- **Brain status bar**: persistent bottom bar in Tauri app showing flow state, fatigue, streak, edit velocity, and focus score. -- **Brain dashboard card**: flow/fatigue/streak + focus progress bar on the EEG dashboard alongside existing BrainStateScores. -- **Brain indicators in ActivityTab**: flow state card (green), fatigue alert card (amber), and streak card (violet) at top. - -### i18n - -- Activity dashboard, clipboard, file history, session replay, weekly report, brain status, timeline, and code-brain correlation translations for all 9 locales. diff --git a/changes/unreleased/feat-brain-awareness.md b/changes/unreleased/feat-brain-awareness.md deleted file mode 100644 index 522f25c4..00000000 --- a/changes/unreleased/feat-brain-awareness.md +++ /dev/null @@ -1,12 +0,0 @@ -### Features - -- **Brain awareness API**: 14 endpoints under `/v1/brain/*` — flow state, cognitive load, meeting recovery, optimal hours, fatigue check, undo struggle, daily brain report, break timing, deep work streak, task type detection, struggle prediction, interruption recovery, code-EEG correlation, and unified timeline. -- **Flow state detector**: real-time check if user is in deep focus (high focus + low switches + sustained editing). -- **Task type detection**: auto-classify coding/debugging/reviewing/refactoring/testing from activity patterns. -- **Struggle prediction**: fuse undo rate + velocity drop + focus decline to predict when user is stuck. Includes actionable suggestion. -- **Interruption recovery measurement**: measure actual focus recovery time per interruption source (Slack avg 12min, Zoom avg 23min, etc.). -- **Fatigue monitor**: 15-minute background check broadcasts `fatigue-alert` and sends OS notification when focus declines. -- **Daily brain report**: 6pm OS notification with morning/afternoon/evening brain summary. -- **Weekly digest notification**: Monday 9am OS notification with weekly summary. ISO week dedup prevents re-fire on daemon restart. -- **Break timing optimizer**: detect natural focus cycle length from 5-minute EEG buckets. -- **Deep work streak**: gamified consecutive days with 60+ minutes of deep work. diff --git a/changes/unreleased/feat-brain-insights.md b/changes/unreleased/feat-brain-insights.md deleted file mode 100644 index 991c09f3..00000000 --- a/changes/unreleased/feat-brain-insights.md +++ /dev/null @@ -1,24 +0,0 @@ -### Features - -- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable EEG-fused insights in one call. -- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — shows how focus level predicts test outcomes. -- **Hourly productivity**: churn volume + undo rate + avg EEG focus by hour of day — identifies peak and trough hours. -- **Context switch recovery**: EEG focus level at each editor/terminal/panel transition — measures the cognitive cost of switching. -- **AI tool impact on focus**: per-app (Claude, Pi) focus delta vs baseline — quantifies whether AI tools help or hurt flow state. -- **Focus by language**: avg EEG focus + undo rate per programming language — identifies which languages are cognitively easiest/hardest. -- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day — shows when edit-test loops are fastest. -- **Tool focus impact**: avg EEG focus per command category (docker, git, deploy, etc.) — reveals which tools correlate with lowest focus. -- **EEG timeseries table**: periodic JSON snapshots of all brain metrics (every 5s during recording). Extensible — new metrics without schema changes. Any event correlatable by timestamp join. -- **EEG timeseries worker**: background thread writes full band powers to `eeg_timeseries` every 5s when an EEG session is active. -- **`POST /v1/brain/eeg-at`**: get EEG metrics at a specific timestamp (nearest sample). -- **`POST /v1/brain/eeg-range`**: get EEG time-series in a range for charts and correlation analysis. -- **Window focus tracking**: `window_focus` events sent when VS Code gains/loses focus. EEG not attributed to coding when VS Code is in background. -- **Live EEG attachment**: `latest_bands` focus/mood injected at event storage time for terminal commands, zone switches, and conversations. - -### UI - -- **Tauri Brain Insights section** in Activity tab: test failure rates by focus level, focus by language bars, AI impact delta, tool focus grid, hourly productivity chart. -- **Tauri EEG focus inline**: terminal commands and conversation messages show EEG focus score (green/yellow/red) at the moment they occurred. -- **VS Code Brain Insights card**: test failure by focus chips, focus by language bars, tool impact chips. Only shown when EEG data is available. -- **VS Code Struggle card**: hidden when no EEG recording (focus is EEG-only, not activity-based). -- **VS Code Flow gauge label**: shows "focus" with EEG, "activity" without — no false claim of brain measurement. diff --git a/changes/unreleased/feat-burn-mlx.md b/changes/unreleased/feat-burn-mlx.md deleted file mode 100644 index f4d152ac..00000000 --- a/changes/unreleased/feat-burn-mlx.md +++ /dev/null @@ -1,9 +0,0 @@ -### Dependencies - -- **burn-mlx**: added `burn-mlx` from git (`eidola-ai/burn-mlx`, branch `burn-0-20`) as optional dependency in `skill-router` and `skill-daemon`. Workspace `[patch.crates-io]` redirects all `burn-mlx` references to the git version (crates.io 0.1.2 targets burn 0.16; project uses burn 0.20). -- **macOS deployment target**: bumped from 10.15 to 14.0 in `.cargo/config.toml` for Metal 3.0 `simdgroup_matrix` intrinsics required by MLX. Still satisfies llama.cpp's `std::filesystem` (available since 10.15). -- **half 2.4**: added to `skill-router` for GPU f16 precision backend type (`CubeBackend`). - -### Features - -- **`mlx-e2e` test suite**: new suite in `scripts/test-all.sh` running UMAP and FFT e2e tests with MLX features. Auto-skips on non-macOS. Available via `npm run test:mlx-e2e`. diff --git a/changes/unreleased/feat-conversations-search.md b/changes/unreleased/feat-conversations-search.md deleted file mode 100644 index de4ad4ea..00000000 --- a/changes/unreleased/feat-conversations-search.md +++ /dev/null @@ -1,18 +0,0 @@ -### Features - -- **Conversations table**: stores all AI coding assistant messages (Claude, Pi) with app, role (user/assistant/tool), text, cwd, timestamp, session ID, EEG focus/mood. -- **FTS5 full-text search**: SQLite virtual table for instant keyword search across all conversation text. `POST /v1/brain/search-conversations {"query":"JWT","mode":"fts"}`. -- **Fuzzy search**: LIKE-based substring matching for partial queries. `{"query":"rate limit","mode":"fuzzy"}`. -- **Structured search**: filter by app, role, time range. `{"mode":"structured","app":"claude","role":"user","since":...}`. -- **Semantic search**: user prompts embedded via fastembed (nomic-embed-text-v1.5, local, no API credits) and stored in HNSW label index. Searchable by meaning, not just keywords. -- **Generic embedding store**: `embeddings` table decoupled from specific data tables. Multi-model support — can re-embed with different models, store multiple vectors per item. Source tracking (source_type, source_id). -- **Code context HNSW index**: separate `code_context_index.hnsw` file for code-specific semantic search, keeping EEG label searches uncontaminated. -- **Conversation events**: VS Code extension sends `conversation_message` events to daemon for each Claude/Pi message. User prompts get embedded; assistant responses and tool calls get FTS-indexed only (saves compute). -- **Session tracking**: messages grouped by JSONL filename (session ID). Timestamps from app's own data (ISO 8601 UTC), not extension read time. -- **Embedding settings**: `neuroskill.embedding.maxInputLength` (default 1000 chars) and `neuroskill.embedding.enableConversations` (default true) in VS Code settings. - -### UI - -- **Tauri AI Conversations section** in Activity tab: timestamped message thread with role icons (user/assistant/tool), app name, EEG focus score, scrollable. -- **VS Code Conversations card**: timestamped messages with role icons, app badge, collapsible. -- **VS Code AI Usage card**: Copilot/Codeium acceptance rate bar, suggestion count, source breakdown chips. diff --git a/changes/unreleased/feat-data-collection.md b/changes/unreleased/feat-data-collection.md deleted file mode 100644 index 607e5934..00000000 --- a/changes/unreleased/feat-data-collection.md +++ /dev/null @@ -1,12 +0,0 @@ -### Features - -- **Meeting detection**: automatically detect Zoom, Teams, Slack, Google Meet, FaceTime, Discord, and Webex meetings from window titles. Track start/end times in `meeting_events` table. -- **Browser tab extraction**: extract page titles from Chrome, Safari, Firefox, Edge, Brave, Arc, Opera, Vivaldi, Chromium. Stored in `browser_title` column on `active_windows`. -- **Clipboard monitoring**: opt-in macOS clipboard change tracking (metadata only — content never stored). Records source app, content type, and size. Includes Automation permission check and settings UI. -- **Multi-monitor awareness**: track windows on secondary monitors across macOS (AppleScript), Linux (wmctrl), Windows (EnumWindows). Dynamic primary screen resolution detection. -- **Undo frequency tracking**: detect undo/redo from file edit diffs via reversal heuristic. `undo_estimate` per 5-second chunk, `undo_count` per file interaction. - -### Bugfixes - -- **Schema migration**: ALTER TABLE for existing databases missing columns added across releases. Runs before DDL to handle all legacy schemas. -- **Retention pruning**: meetings, clipboard, and secondary_windows pruned alongside file interactions during hourly maintenance. diff --git a/changes/unreleased/feat-dependabot-fixes.md b/changes/unreleased/feat-dependabot-fixes.md deleted file mode 100644 index 6c79a615..00000000 --- a/changes/unreleased/feat-dependabot-fixes.md +++ /dev/null @@ -1,11 +0,0 @@ -### Dependencies - -- **Fix rand 0.7.3 vulnerability**: vendored `phf_generator 0.8.0` with rand bumped from 0.7 to 0.8, eliminating the vulnerable transitive dependency. -- **Remove atty**: migrated `iroh_test_client` from `structopt` (clap v2) to `clap v4` derive, dropping the unmaintained `atty` crate. -- **Update llama-cpp-4 to 0.2.50**: upstream llama.cpp fixes including `common_*` symbol renames. -- **Update kittentts to 0.4.1**: TTS engine update. -- **Add grayscale 0.0.1**: macOS grayscale display control for DND mode. - -### Bugfixes - -- **Dismiss stale Dependabot alerts**: crossbeam-deque, crossbeam-utils, crossbeam-queue, memoffset, and glib alerts dismissed (already at patched versions or fixed in fork). diff --git a/changes/unreleased/feat-design-system.md b/changes/unreleased/feat-design-system.md deleted file mode 100644 index ae6b1838..00000000 --- a/changes/unreleased/feat-design-system.md +++ /dev/null @@ -1,19 +0,0 @@ -### Features - -- **Design tokens** (`webview-ui/src/lib/tokens.css`): 30+ CSS custom properties organized into core palette (5 colors), surfaces (4 levels), backgrounds (14 translucent tints), borders (3 colors), semantic aliases (7), and typography (3). Zero inline `rgba()` in App.svelte. -- **Reusable Svelte components** (`webview-ui/src/lib/`): - - `Card` — wrapper with title, info button, info panel, header-right slot, variant borders (warn/danger/info) - - `MetricRow` — label + value pair with variant colors (warn/accent/dim/good/bad) - - `Chevron` — collapsible section with chevron toggle, count badge, slot content - - `ProgressBar` — horizontal bar with value/max, 5 color variants, optional label - - `Gauge` — circular SVG ring with animated fill, value, label - - `Badge` — text badge with 7 variants (default/good/warn/bad/blue/live/score-circle/si) - - `Callout` — alert box with 3 variants (warn/danger/info) -- **Component index** (`webview-ui/src/lib/index.ts`): barrel export for all components. -- **Timestamp compliance**: 25 automated tests (`src/test-timestamps.ts`) verifying: - - No `toLocaleTimeString`/`toLocaleDateString` in data layer (activity-tracker, events, brain, config, vt-parser) - - `toLocaleTimeString` used in UI layer (App.svelte) for display - - `Date.now()` returns UTC milliseconds - - ISO 8601 strings parsed to UTC millis - - No hardcoded timezone offsets in data layer - - All stored timestamps are UTC; local conversion only at UI boundary diff --git a/changes/unreleased/feat-dev-loops.md b/changes/unreleased/feat-dev-loops.md deleted file mode 100644 index 7bcb5493..00000000 --- a/changes/unreleased/feat-dev-loops.md +++ /dev/null @@ -1,10 +0,0 @@ -### Features - -- **Dev loop detection**: identifies edit-build-test cycles from terminal command history. Groups consecutive runs of the same build/test command into loops. -- **`dev_loops` table**: loop type, command, iteration count, pass/fail counts, avg cycle time, fastest/slowest cycle, EEG focus start/end, focus trend (rising/falling/stable). -- **`POST /v1/brain/dev-loops`**: returns detected loops for a time window with all metrics. -- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages." -- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl → infrastructure (0.8), deploy → deploying (0.85), git dominating → git_management (0.7), test → testing (0.85), debug → debugging (0.9). -- **Dev loops in sidebar**: command, iteration count, pass rate, avg cycle time, focus trend arrow. Failing loops get red left border. -- **Dev loops in Tauri Activity tab**: expanded view with pass rate percentage, cycle time, focus trend. -- **Loop efficiency by hour**: insight showing build/test cycle time + pass rate by time of day. diff --git a/changes/unreleased/feat-exg-inference-backend.md b/changes/unreleased/feat-exg-inference-backend.md deleted file mode 100644 index 2ac7759d..00000000 --- a/changes/unreleased/feat-exg-inference-backend.md +++ /dev/null @@ -1,9 +0,0 @@ -### Features - -- **Inference backend selector**: `exg_inference_device` setting expanded from `gpu | cpu` to `auto | mlx | gpu | cpu`. Default changed from `gpu` to `auto` (picks MLX on macOS, GPU elsewhere). -- **Four-button selector in EXG Settings**: Auto, MLX, GPU, CPU — each with localized label and description. Reconnect headset hint shown after change. -- **`embed-exg-mlx` feature**: new daemon Cargo feature that enables `skill-router/mlx`, `skill-eeg/mlx`, and `embed-zuna-mlx` together. - -### i18n - -- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). diff --git a/changes/unreleased/feat-fast-umap-1.6.md b/changes/unreleased/feat-fast-umap-1.6.md deleted file mode 100644 index 7c5ef60b..00000000 --- a/changes/unreleased/feat-fast-umap-1.6.md +++ /dev/null @@ -1,24 +0,0 @@ -### Features - -- **fast-umap 1.6.0**: updated from 1.5.1 with MLX, PCA, and nearest-neighbor descent support. -- **MLX UMAP backend**: Apple Silicon native UMAP projection via `burn-mlx`. Runtime dispatch between MLX and GPU (wgpu) based on user preference. Auto defaults to MLX on macOS, GPU elsewhere. -- **Precision selector**: F32 / F16 precision for the GPU (wgpu) backend. MLX is F32-only (fast-umap trait constraint). Exposed in UMAP settings as chip group. -- **Backend & precision UI**: new "Compute Backend" section in UMAP settings with Auto / MLX / GPU chips and F32 / F16 precision chips. Pipeline summary badge shows active backend and precision. - -### Performance - -- **MLX vs GPU benchmarks** (Mac mini, Apple Silicon): - -| Dataset | Points | GPU (wgpu) | MLX | Speedup | -|---|---|---|---|---| -| Small | 200 | 120.9 s | 2.3 s | **51x** | -| Medium | 1,000 | 136.6 s | 7.1 s | **19x** | -| Large | 5,000 | 152.8 s | 23.8 s | **6.4x** | - -### Features - -- **UMAP e2e benchmarks**: `umap_e2e_small` (200 pts), `umap_e2e_medium` (1K pts), `umap_e2e_large` (5K pts) with synthetic 32-dim EEG embeddings. Reports backend, timing, throughput (pts/sec), and separation score. - -### i18n - -- Added `umapSettings.backend`, `umapSettings.backendDesc`, `umapSettings.precision`, `umapSettings.precisionDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). diff --git a/changes/unreleased/feat-gpu-fft-1.2.md b/changes/unreleased/feat-gpu-fft-1.2.md deleted file mode 100644 index c6f6a6d8..00000000 --- a/changes/unreleased/feat-gpu-fft-1.2.md +++ /dev/null @@ -1,8 +0,0 @@ -### Features - -- **gpu-fft 1.2.0**: updated from 1.1.1 with MLX FFT backend. EEG signal filtering (overlap-save convolution) uses MLX when `skill-eeg/mlx` is enabled. ~3.7x faster than wgpu at N=65536. -- **`skill-eeg/mlx` feature**: new feature gate that enables `gpu-fft/mlx` alongside `gpu-fft/wgpu`. Wired into `embed-exg-mlx` daemon feature. - -### Features - -- **FFT MLX e2e**: `fft_e2e_roundtrip_256`, `fft_e2e_batch_4ch`, `fft_e2e_psd_peak_detection`, `fft_e2e_large_batch` (32 channels x 1024 samples). Validates round-trip accuracy, PSD peak detection, and batch throughput. diff --git a/changes/unreleased/feat-grayscale-dnd.md b/changes/unreleased/feat-grayscale-dnd.md deleted file mode 100644 index 0c26d0cf..00000000 --- a/changes/unreleased/feat-grayscale-dnd.md +++ /dev/null @@ -1,7 +0,0 @@ -### Features - -- **Grayscale display mode**: optional system-wide grayscale that activates/deactivates in sync with Do Not Disturb. Reduces visual distraction during deep work. macOS only, default off. - -### i18n - -- Added `dnd.grayscale` and `dnd.grayscaleDesc` translations to all 9 locales. diff --git a/changes/unreleased/feat-history-search-integration.md b/changes/unreleased/feat-history-search-integration.md deleted file mode 100644 index d41f7a37..00000000 --- a/changes/unreleased/feat-history-search-integration.md +++ /dev/null @@ -1,5 +0,0 @@ -### Features - -- **File activity in history sessions**: expanded EEG sessions show files worked on during the session with focus indicators, edit counts (+/-), language, and meeting interruption badges. -- **File activity in interactive search**: `file_activity` nodes in the 3D search graph linked to EEG epochs and text labels, showing which files were being edited during matching brain states. -- **Meeting nodes in search graph**: detected meetings appear alongside EEG results in the interactive search as amber nodes with temporal proximity edges. diff --git a/changes/unreleased/feat-neuroskill-cli.md b/changes/unreleased/feat-neuroskill-cli.md deleted file mode 100644 index 23eec985..00000000 --- a/changes/unreleased/feat-neuroskill-cli.md +++ /dev/null @@ -1,5 +0,0 @@ -### CLI - -- **`neuroskill activity` expanded**: 19 subcommands — bands, window, input, windows, files, top-files, top-projects, languages, sessions, meetings, clipboard, builds, heatmap, switches, summary, score, digest, stale, timeline. -- **`neuroskill brain` added**: 14 subcommands — flow, load, load-files, recovery, optimal, fatigue, struggle, report, breaks, streak, task, stuck, interruptions, correlation. -- **`neuroskill vscode` added**: auto-build and install VS Code/VSCodium/Cursor extension from source on macOS, Linux, and Windows. diff --git a/changes/unreleased/feat-search-ui-redesign.md b/changes/unreleased/feat-search-ui-redesign.md deleted file mode 100644 index 6a3104d6..00000000 --- a/changes/unreleased/feat-search-ui-redesign.md +++ /dev/null @@ -1,11 +0,0 @@ -### UI - -- **Search mode dropdown**: replaced 4-tab bar with a styled dropdown supporting 7 search modes — Interactive, EEG, Text, Images, Code, Meetings, Brain. -- **Diamond EEG nodes**: EEG epochs rendered as faceted octahedrons (diamonds) in the 3D search graph. Meeting nodes rendered as tetrahedrons (pyramids). Visual distinction by data type. -- **New search modes**: Code (file activity + brain state), Meetings (meeting events + recovery), Brain (flow/fatigue/struggle insights). -- **Terminal commands in Cmd-K**: added 4 terminal commands to the command palette — Recent Terminal Commands, Terminal Focus Impact, Context Switch Cost, Dev Loops. Translated labels in all 9 locales. - -### i18n - -- Search mode labels (Code, Meetings, Brain) translated in all 9 locales. -- Terminal command palette entries translated in all 9 locales. diff --git a/changes/unreleased/feat-sidebar-cards.md b/changes/unreleased/feat-sidebar-cards.md deleted file mode 100644 index ee0c48e0..00000000 --- a/changes/unreleased/feat-sidebar-cards.md +++ /dev/null @@ -1,30 +0,0 @@ -### Features - -- **Git context card**: current branch (monospace), dirty/staged file count, ahead/behind indicators. Uses VS Code git extension API. -- **Session timeline card**: horizontal bar chart showing activity events by hour of day. Color-coded by volume. -- **Workspace activity card**: per-file edits, lines added/removed, focus time. Grouped by workspace folder (project), sorted by activity. Active file indicator (blue dot). Top 10 files per project. -- **Environment card**: editor/terminal/panel time split (colored stacked bar), tab count, editor groups, terminal count. Per-terminal cards with shell type, CWD, PID, shell integration status. -- **Terminal I/O sections**: collapsible with chevrons, minimized by default. Input shows timestamped commands with CWD. Output shows VT-parsed session content. -- **Terminal input card**: keystroke intensity per program (keys/min), duration, collapsible behind chevron. -- **Terminal impact card**: EEG focus delta by command category with pass rate percentage. -- **Context switch cost card**: focus level at each zone transition type with switch count. -- **Dev loops card**: edit-build-test cycles with iteration count, pass/fail rate, cycle time, focus trend (rising/falling/stable arrow). -- **Today's report card**: productivity score, morning/afternoon/evening period breakdown with focus bars and churn. -- **Energy card**: fatigue bar (inverse of focus decline), continuous work time, streak badge. -- **Struggle card**: EEG-only (hidden without recording), score 0-100, contributing factors. -- **Optimal hours card**: peak/avoid hours grid. -- **AI usage card**: acceptance rate bar, suggestion count, source breakdown. -- **Today vs yesterday card**: files and churn comparison with directional arrows. -- **Code review detection**: auto-detected when file switches > 5 and edit velocity < 1 line/min. -- **Process monitor card**: running dev servers (vite, next, webpack, cargo, node, python, docker, postgres) with ports and PIDs. -- **Info toggles**: every card has a `?` button explaining how metrics are calculated. -- **Dual-speed updates**: local data every 5s (visible), brain data every 30s. Pauses when sidebar hidden. - -### UI - -- **Configurable alerts**: `neuroskill.alerts.focusLow` (warn when EEG focus drops below threshold), `neuroskill.alerts.continuousWorkMins` (warn after N minutes continuous work). -- **Daily digest notification**: at configurable time (default 17:30), shows productivity score + stats. "Full Report" button opens detail panel. -- **Full report panel**: `Cmd+Shift+R` opens full-width webview with same data as sidebar. -- **Keyboard shortcuts**: `Cmd+Shift+N` (toggle sidebar), `Cmd+Shift+R` (full report), `Cmd+Shift+Alt+N` (reconnect). -- **Open NeuroSkill button**: launches native app (cross-platform). -- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk. Auto-flushes on reconnect. diff --git a/changes/unreleased/feat-terminal-tracking.md b/changes/unreleased/feat-terminal-tracking.md deleted file mode 100644 index 9e7e3b51..00000000 --- a/changes/unreleased/feat-terminal-tracking.md +++ /dev/null @@ -1,24 +0,0 @@ -### Features - -- **OS-wide shell hooks**: preexec/precmd hooks for zsh, bash, fish, PowerShell. Background curl sends every command to daemon — zero delay to prompt. Self-contained scripts generated by daemon, stored in `~/.skill/shell-hooks/`. -- **Shell hook management**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check per shell. -- **Terminal command table**: `terminal_commands` with command text, binary name, args, cwd, exit code, duration, auto-categorized (284 CLI tools across 18 categories), EEG focus at start + end for delta calculation. -- **Binary extraction**: raw binary name stored separately from args. Lazy categorization — `POST /v1/brain/recategorize-commands` reruns rules on all historical data. `POST /v1/brain/binary-stats` discovers uncategorized tools by frequency. -- **284 CLI tools recognized**: git, gh, glab, hf, docker, kubectl, helm, aws, gcloud, az, brew, pip, cargo, npm, psql, redis-cli, ollama, claude, pi, vim, tmux, htop, terraform, and 260+ more across 18 categories (build, test, run, git, docker, deploy, install, navigate, debug, network, database, ai, editor, multiplexer, monitor, env, system, other). -- **Script session recording**: `script` PTY proxy wraps shell sessions, captures all terminal I/O (including input inside interactive programs). Logs stored in `~/.skill/terminal-logs/` with auto-rotation. -- **VT100 terminal emulator**: `vt-parser.ts` replays script recordings through a virtual terminal with scrollback capture. Handles cursor movement, screen clears, alternate screen buffer. Separates input from output via prompt pattern detection. -- **App-specific JSONL parsing**: reads Claude Code (`~/.claude/projects/`) and Pi agent (`~/.pi/agent/sessions/`) conversation files directly. Extracts user prompts with real timestamps, assistant responses, and tool calls. Supports multiple parallel sessions. -- **Readline history polling**: monitors `~/.python_history`, `~/.node_repl_history`, `~/.irb_history`, `~/.psql_history` for REPL input. -- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). -- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons with health indicators (green/yellow/red dot), recent commands preview, "How it works" documentation. - -### Server - -- **`POST /v1/activity/shell-command`**: receives commands from shell hooks with command text, cwd, shell type, exit code. -- **`POST /v1/brain/terminal-commands`**: query recent commands with exit codes, durations, categories, EEG correlation. -- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category. -- **`POST /v1/brain/binary-stats`**: usage frequency per binary for discovering uncategorized tools. -- **`POST /v1/brain/recategorize-commands`**: rerun categorization rules on all historical commands. -- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end. -- **Zone switch events**: editor/terminal/panel transitions stored with EEG focus snapshot. `POST /v1/brain/context-cost` returns focus at each transition type. -- **Layout snapshots**: periodic (60s) tab/group/terminal counts from VS Code. diff --git a/changes/unreleased/feat-validation-daemon.md b/changes/unreleased/feat-validation-daemon.md deleted file mode 100644 index c32daedf..00000000 --- a/changes/unreleased/feat-validation-daemon.md +++ /dev/null @@ -1,16 +0,0 @@ -### Features - -- **Validation / fatigue-research daemon backend**: foundations for calibrating the Break Coach and Focus Score against four external instruments — KSS (Karolinska Sleepiness Scale), NASA-TLX (workload), PVT (Psychomotor Vigilance Task), and an EEG-derived fatigue index per Jap et al. 2009. -- **`skill-data::validation_store`** — new SQLite store at `~/.skill/validation.sqlite` with five tables (`config`, `kss_responses`, `tlx_responses`, `pvt_runs`, `prompt_log`). Persistent config is a single-row JSON blob with serde defaults so adding a new channel later is a non-migration. New constant `VALIDATION_FILE` in `skill-constants`. -- **EEG fatigue index**: pure function `eeg_fatigue_index(bands) → Option` computing `(α + θ) / β` over the existing band-power snapshot. Guards against missing bands and zero-β. Passive; ships **on** because it costs nothing when no headset is attached. -- **Pure scheduler `decide_prompt(ctx, ...) → PromptDecision`**: respects the `respect_flow` master gate, configurable quiet hours, per-channel daily caps, runtime snoozes, and rate limits. Channel ordering: KSS first (lightest), TLX after a long task unit, PVT on a weekly cadence. Live `read_in_flow` and `read_break_coach_active` open the activity store read-only and ask `flow_state_now(300).in_flow` and `fatigue_check().fatigued` so the gates actually mean something. -- **Sane defaults — opt-in everywhere**: KSS, TLX, PVT all ship `enabled = false`. Only the passive EEG fatigue index ships on. The `respect_flow` master gate ships on. - -### Server - -- **`/v1/validation/*` HTTP endpoints** (axum): `GET /config`, `PATCH /config` (recursive JSON merge for partial updates), `POST /snooze`, `POST /disable-today`, `GET /should-prompt` (returns the daemon's prompt decision; logs the fire so the rate-limiter sees it), `POST /kss`, `POST /tlx`, `POST /pvt`, `POST /close-prompt`, `GET /results`, `GET /fatigue-index` (live `(α+θ)/β` from the latest band snapshot). -- **`AppState.validation_runtime`** (`crates/skill-daemon-state/src/state.rs`): new `Arc>` field holding ephemeral snooze/disable-today state. Resets on daemon restart by design — a crash loop shouldn't keep prompts suppressed forever. - -### Bugfixes - -- **CORS allow-methods now includes `PATCH`**: `crates/skill-daemon/src/main.rs` was advertising only `GET, POST, PUT, DELETE, OPTIONS`, so the browser preflight blocked `PATCH /v1/validation/config` from the Tauri webview. Added `Method::PATCH` to the list. diff --git a/changes/unreleased/feat-validation-tauri-ui.md b/changes/unreleased/feat-validation-tauri-ui.md deleted file mode 100644 index b5be6ae7..00000000 --- a/changes/unreleased/feat-validation-tauri-ui.md +++ /dev/null @@ -1,18 +0,0 @@ -### Features - -- **Validation & Research settings tab** (`src/lib/settings/ValidationTab.svelte`): new top-level settings tab gathering five `SettingsCard` blocks — global gates (`respect_flow`, quiet hours), KSS, NASA-TLX, PVT, and EEG fatigue index — plus a Calibration Week button and a recent-results card. - - Every toggle / numeric input PATCHes one nested field via `daemonPatch("/v1/validation/config", …)`; the daemon's recursive JSON merge keeps the rest of the config untouched. - - EEG fatigue card shows the live `(α + θ) / β` value polled every 5 s from `/v1/validation/fatigue-index`. - - Calibration Week: single button that batch-updates all four channels to higher-frequency presets (KSS 8/day, TLX after every flow block ≥ 20 min, PVT mid-week) in one PATCH. -- **PVT panel** (`src/lib/settings/PvtPanel.svelte`): full 3-minute Psychomotor Vigilance Task. Random ITIs (2–10 s), green-dot stimulus, `performance.now()` for sub-millisecond RT measurement, tracks responses + false starts. On finish computes mean RT, median RT, slowest-10% mean RT (anticipation-resistant), and lapse count (RT > 500 ms per Dinges & Powell 1985), POSTs to `/v1/validation/pvt`. State machine: intro → running → done. -- **NASA-TLX form** (`src/lib/settings/TlxForm.svelte`): six-slider modal for the raw (un-weighted) NASA-TLX. Performance scale uses inverted endpoints ("Failure" → "Perfect") per Hart 2006. POSTs to `/v1/validation/tlx` with `task_kind`, `task_duration_secs`, optional `prompt_id` echo-back. -- **Pure stats helper** (`src/lib/settings/pvt-stats.ts`): extracted `mean`, `median`, `slowest10Mean`, `lapseCount`, `computeStats` from the PVT panel so the math is unit-testable without a Svelte renderer. -- **`daemonPatch(path, body)`** in `src/lib/daemon/http.ts` — needed for the validation config endpoint. - -### UI - -- **Spacing pass on the validation tab and modals**: every card uses `flex flex-col gap-5 p-6` instead of the old `space-y-4` (which provided no padding override). Conditional sub-settings under a channel toggle are now indented under a left border (`ml-1 border-l-2 border-border pl-5`) so the parent/child relationship reads visually. Modals padded to `p-8` with `gap-3` button rows. TLX sliders gained `mt-1` lift off the description and `uppercase tracking-wide` low/high legend labels. - -### i18n - -- **New `validation` namespace** in all 9 languages (`src/lib/i18n/{de,en,es,fr,he,ja,ko,uk,zh}/validation.ts`): ~95 keys in English (full coverage), ~55 in each other language for the user-visible strings (long-tail descriptions fall back via the runtime `t()` chain). `index.ts` barrel exports updated for every language. diff --git a/changes/unreleased/feat-validation-tests.md b/changes/unreleased/feat-validation-tests.md deleted file mode 100644 index faeaccb2..00000000 --- a/changes/unreleased/feat-validation-tests.md +++ /dev/null @@ -1,11 +0,0 @@ -### Features - -- **Test coverage for the validation feature across all three layers** — 72 tests total, all passing. - - **Rust unit tests** (`crates/skill-data/src/validation_store.rs`): 19 tests covering config persistence, store round-trips, the `(α + θ) / β` fatigue index (Jap et al. 2009) including zero-β and missing-band edge cases, every scheduler branch (flow respect, quiet hours, snooze blocking, KSS rate limit, TLX trigger after long task, TLX skip on short task, PVT weekly cadence, PVT silence after recent run, quiet-window-equals-end semantics), and prompt-log queries. - - **Rust HTTP integration tests** (`crates/skill-daemon/src/routes/validation.rs`): 10 tests using `tower::ServiceExt::oneshot()` against a tempdir-backed `AppState` — `GET /config` defaults, `PATCH /config` partial-update + round-trip, `POST /kss` happy path, KSS score-out-of-range → 400, TLX subscale-out-of-range → 400, `GET /should-prompt` returns `{kind: "none"}` with default config, `POST /snooze` envelope, plus three `merge_json` tests covering deep nesting. - - **Vitest — PVT statistics** (`src/tests/pvt-stats.test.ts`, 12 tests): edge cases for `mean`, `median`, `slowest10Mean` (including the n<10 fallback), `lapseCount` with the 500 ms Dinges & Powell threshold and explicit-threshold override, plus a known-fixture `computeStats` round-trip. - - **Vitest — i18n coverage** (`src/tests/validation-i18n.test.ts`, 23 tests): every VS Code l10n bundle exists and contains every user-facing key (KSS prompt + scores 1/5/9, TLX/PVT prompts, all four escape-hatch labels); every Tauri language `validation.ts` imports cleanly; English Tauri bundle covers every required key; every non-English Tauri bundle has at least the tab name and disclaimer translated. - -### Refactor - -- **Sidebar disclaimer test now accepts either localiser**: existing `vscode-sidebar-disclaimer.test.ts` updated so the `vscode.l10n.t("sidebar.disclaimer")` ↔ `tr("sidebar.disclaimer")` migration doesn't break the assertion. Both look up the same key. diff --git a/changes/unreleased/feat-validation-vscode-extension.md b/changes/unreleased/feat-validation-vscode-extension.md deleted file mode 100644 index ae058eb6..00000000 --- a/changes/unreleased/feat-validation-vscode-extension.md +++ /dev/null @@ -1,13 +0,0 @@ -### Features - -- **VS Code extension joins the validation prompt loop**: new `ValidationManager` (`extensions/vscode/src/validation.ts`) polls `/v1/validation/should-prompt` every 90 s and renders whichever channel the daemon decides to fire. - - **KSS** (1–9 sleepiness) — full QuickPick with Karolinska wording, plus Snooze 30m / Don't ask today / Stop these prompts escape hatches in-line. POSTs the answer to `/v1/validation/kss` echoing the daemon's `prompt_id` so the prompt log can mark it answered. - - **NASA-TLX** — fallback path: shows an information message offering to open the form in the Tauri app (deep link `neuroskill://validation/tlx`) plus the same escape hatches. - - **PVT** — weekly nudge: offers to deep-link into the Tauri PVT panel (`neuroskill://validation/pvt`) or skip a week (snoozes for 6 days so the next reminder fires on cadence). -- **Two new commands**: `NeuroSkill: Open Validation Settings…` (deep links into the Tauri preferences pane) and `NeuroSkill: Check for Validation Prompt Now` (forces a single scheduler poll — useful for opt-in onboarding). -- **`DaemonClient.patch()`**: extension's daemon client gained a PATCH method so the "Stop these prompts" escape hatch can flip `enabled = false` on the persistent config. - -### i18n - -- **22 new `validation.*` keys per bundle** in all 9 languages (`bundle.l10n.{de,en,es,fr,he,ja,ko,uk,zh-cn}.json`): KSS prompt + 9 score labels with translated Karolinska wording, TLX/PVT prompts, all four escape-hatch labels, the disabled-permanently acknowledgement. -- **Two new `cmd.*` keys** for the validation commands in `package.nls.json`. diff --git a/changes/unreleased/feat-vscode-extension-ci.md b/changes/unreleased/feat-vscode-extension-ci.md deleted file mode 100644 index 0efee66b..00000000 --- a/changes/unreleased/feat-vscode-extension-ci.md +++ /dev/null @@ -1,7 +0,0 @@ -### Build - -- **CI for the VS Code extension** (`extensions/vscode/.github/workflows/`): two workflows live in the submodule repo (`vscode-neuroskill`). - - **`ci.yml`** — runs on every PR and `main` push. `npm ci` → `tsc` build → `vsce package` → uploads the `.vsix` as a 14-day artifact. No secrets, no publish. - - **`release.yml`** — fires two ways. *Tag push* (`git push --tags` after `npm version patch`) verifies the tag matches `package.json` and publishes. *Manual dispatch* with a `patch | minor | major | x.y.z` input bumps `package.json`, commits, tags, pushes, then publishes. Both paths run `npx @vscode/vsce publish` (uses `VSCE_PAT`) and `npx ovsx publish` (uses `OVSX_PAT`), then create a GitHub release with the `.vsix` attached and the latest `## ` block from `CHANGELOG.md` as release notes. -- **Skip-on-missing-secret**: if either `VSCE_PAT` or `OVSX_PAT` is unset, the corresponding publish step emits a `::warning::` and is skipped — letting you wire up Open VSX later without breaking the workflow. -- **Releasing section in the extension README**: documents the secret setup (Azure DevOps PAT, Open VSX token), the two release flows, and the one-time namespace claims (Marketplace publisher registration + `ovsx create-namespace`). diff --git a/changes/unreleased/feat-vscode-extension.md b/changes/unreleased/feat-vscode-extension.md deleted file mode 100644 index dedc2f14..00000000 --- a/changes/unreleased/feat-vscode-extension.md +++ /dev/null @@ -1,84 +0,0 @@ -### Features - -- **VS Code extension**: separate repo (`NeuroSkill-com/vscode-neuroskill`) as git submodule at `extensions/vscode/`. 50+ tracked event types across editing, navigation, debugging, git, AI, terminal, clipboard, and more. -- **Sidebar webview (Svelte 5)**: full brain dashboard in the VS Code activity bar with circular flow gauge, metrics strip, daily report, energy, struggle, optimal hours, workspace activity, environment, terminal impact, context cost, and dev loops. -- **Activity bar icon**: neural network SVG icon in the VS Code sidebar; NeuroSkill logo (PNG) in the webview header. -- **Brain status bar in VS Code**: polls daemon every 30s showing flow state, fatigue, streak, task type, and struggle score. Shows "offline" when daemon unreachable. Notifications for fatigue and struggle. -- **Dual-speed sidebar updates**: local data (files, terminals, layout) refreshes every 5s when sidebar is visible; brain endpoints every 30s. Pauses when hidden. -- **Event caching**: offline-resilient `pendingEvents` array (10K cap) persisted to disk via `globalStorageUri`. Auto-flushes on reconnect. Survives VS Code restarts. Cache count shown in status bar tooltip. -- **Workspace activity tracking**: per-file edits, lines added/removed, focus time, active file indicator. Grouped by workspace folder (project), sorted by activity. Top 10 files per project. -- **Terminal command tracking**: full command text, exit code, cwd, output streaming (via `execution.read()`), shell type detection (zsh/bash/fish/powershell/cmd/node/python), focus time per terminal, PID. Expandable command output in sidebar. -- **Terminal shell integration**: captures commands via `onDidStartTerminalShellExecution` / `onDidEndTerminalShellExecution` (VS Code 1.93+). CWD tracked via `onDidChangeTerminalShellIntegration`. -- **Zone tracking**: editor/terminal/panel time split with stacked color bar (blue/green/yellow). Layout chips showing tab count, editor groups, terminal count. -- **Auto EEG labeling**: every significant VS Code event auto-inserts a searchable label into EEG recordings with smart categorization (editing, debugging, git commits, AI assistance, meetings, errors, navigation, terminal commands, zone switches). -- **Inline label embedding**: auto-labels embedded immediately via fastembed for instant searchability (no idle reembed wait). -- **Command execution tracking**: 40+ VS Code commands tracked (go-to-definition, rename, find, format, fold, git, AI, debug, layout) with semantic categorization. -- **IntelliSense acceptance detection**: multi-char single-line insertions heuristically identified as autocomplete acceptances. -- **Clipboard tracking**: clipboard content changes polled every 5s with debounce. -- **Layout snapshots**: periodic (60s) capture of editor groups, visible editors, open tabs, terminal count sent to daemon. -- **Zone switch events**: editor/terminal/panel transitions sent to daemon with EEG focus snapshot. -- **File system watcher**: selective watching of package.json, Cargo.toml, go.mod, .git/HEAD for external change detection. -- **Environment context**: one-time capture of appHost, remoteName, shell, uiKind, language. -- **Info toggles**: every sidebar card has a `?` button explaining how metrics are calculated (flow score formula, struggle signals, energy bar, optimal hours, dev loops, terminal impact, context cost). -- **Open NeuroSkill button**: launches native app from sidebar (cross-platform: `open -a` macOS, `start` Windows, `xdg-open` Linux). Also in command palette. -- **Command palette → sidebar**: `Show Brain Status`, `Today's Report`, `Am I Stuck?`, `Best Time to Code` now open sidebar and scroll to relevant section instead of showing toast notifications. - -### Server - -- **`terminal_commands` table**: shell command text, cwd, exit code, duration, auto-categorized (50+ patterns: build/test/run/git/docker/deploy/install/navigate/debug/network/other), EEG focus at start and end for delta calculation. -- **`dev_loops` table**: edit→build/test cycle tracking with iteration count, pass/fail rate, avg cycle time, focus trend (rising/falling/stable). -- **`zone_switches` table**: editor/terminal/panel transitions with EEG focus snapshot at moment of switch. -- **`layout_snapshots` table**: periodic tab/group/terminal counts from VS Code. -- **Event handler**: new match arms for `terminal_command_start`, `terminal_command_end`, `zone_switch`, `layout_snapshot` in `activity_vscode_events_impl()`. -- **Command categorizer**: `categorize_command()` with 50+ patterns across build, test, run, git, docker, deploy, install, navigate, debug, network. -- **`POST /v1/brain/terminal-impact`**: avg EEG focus delta by command category — shows how builds, tests, git, docker affect brain state. -- **`POST /v1/brain/context-cost`**: focus level at each zone transition type with switch counts. -- **`POST /v1/brain/dev-loops`**: edit-build-test cycle detection with iterations, pass rate, cycle time, focus trend. -- **`POST /v1/brain/terminal-commands`**: recent commands with exit codes, durations, categories, EEG correlation. -- **Enhanced `predict_struggle()`**: terminal failures (+8/fail, max +40) and re-running same failing command 3+ times (+15/rerun, max +30) boost struggle score. New suggestions: "You're re-running the same failing command", "Multiple failures — read the error messages". -- **Enhanced `detect_task_type()`**: terminal command categories override heuristics with higher confidence — docker/kubectl→infrastructure (0.8), deploy→deploying (0.85), git dominating→git_management (0.7), test→testing (0.85), debug→debugging (0.9). -- **Terminal EEG auto-labels**: `"running: cargo test"` on start, `"cargo test passed"` / `"cargo test failed (exit 1)"` on end, `"switched to terminal"` on zone changes. -- **AI events table**: `ai_events` SQLite table tracking suggestion shown/accepted/rejected and chat sessions with source attribution. -- **OS-wide shell hooks**: `scripts/shell-hooks/` with preexec/precmd hooks for zsh, bash, fish, PowerShell. Sends every command to daemon via background curl. No delay to prompt. -- **Shell hook daemon endpoints**: `GET /v1/activity/shell-hook?shell=zsh` returns hook script, `POST /v1/activity/install-shell-hook` writes to `~/.skill/shell-hooks/` and appends to rc file, `POST /v1/activity/uninstall-shell-hook` removes cleanly, `POST /v1/activity/shell-hook-status` health check. -- **`neuroskill terminal` CLI**: `status` (hook health per shell), `install [shell]`, `uninstall [shell]`, `commands` (recent tracked), `impact` (focus delta by category), `loops` (dev loop detection). -- **`neuroskill brain` new subactions**: `terminal-impact`, `context-cost`, `dev-loops`. -- **`neuroskill activity` new subaction**: `terminal-commands`. -- **Tauri Terminal settings tab**: per-shell install/uninstall/repair buttons, health indicators (green/yellow/red), recent commands preview, "How it works" documentation. -- **`neuroskill vscode` CLI**: auto-install extension to VS Code, VSCodium, or Cursor on macOS, Linux, and Windows. -- **Meeting nodes in search graph**: meetings appear as amber nodes in the interactive 3D search graph linked by `meeting_prox` edges. - -### Features - -- **Developer insights endpoint**: `POST /v1/brain/developer-insights` returns 7 actionable insights in one call. -- **Test failure by focus level**: correlates build/test exit codes with EEG focus (high/mid/low) — "your tests fail 45% more when focus is low." -- **Hourly productivity**: churn + undo rate + avg focus by hour of day — "you write 3x more bugs after 2pm." -- **Context switch recovery**: focus level at editor/terminal/panel transitions — "switching to terminal costs 4 focus points." -- **AI tool impact**: Claude/Pi focus delta vs baseline — "Claude conversations drop focus by 8 points." -- **Focus by language**: avg EEG focus + undo rate per programming language — "best focus in Rust, worst in CSS." -- **Dev loop efficiency by hour**: build/test cycle time + pass rate by time of day. -- **Tool focus impact**: avg focus when using each command category (docker, git, deploy, etc.). - -### Refactor - -- **Conversations table + FTS5**: full-text search on all AI conversation messages (user/assistant/tool). Three search modes: FTS, fuzzy, structured. -- **EEG timeseries table**: periodic JSON snapshots of all brain metrics. Extensible — new metrics without schema changes. Join-at-query-time correlation with any event. -- **Embeddings store**: generic, multi-model. User prompts embedded via fastembed (local, no API credits). Can re-embed with different models. -- **Code context HNSW index**: separate from label index for code-specific semantic search. -- **Binary extraction**: terminal commands store raw binary name + args separately. Lazy categorization — 284 CLI tools recognized, rerun anytime. -- **EEG attachment**: live focus/mood from `latest_bands` injected at event storage time. -- **EEG timeseries worker**: writes full band powers to `eeg_timeseries` every 5s during recording. - -### UI - -- **Design tokens** (`tokens.css`): 30+ CSS custom properties — palette, surfaces, backgrounds, borders, semantic, typography. Zero inline rgba() in sidebar. -- **7 reusable Svelte components**: Card, MetricRow, Chevron, ProgressBar, Gauge, Badge, Callout. -- **Timestamp compliance**: 25 tests verifying UTC in data layer, local conversion only in UI. - -### Docs - -- VS Code extension design plan at `docs/vscode-extension.md`. -- `neuroskill-activity` skill documentation with 18 activity + 24 brain subcommands, terminal integration, shell hook reference, command categorization table. -- Updated `neuroskill-dnd` skill with grayscale mode. -- Updated `neuroskill/README.md` with terminal, brain awareness, and VS Code extension features. -- Updated `skills/SKILL.md` index with terminal tracking skill reference. diff --git a/changes/unreleased/feat-vscode-readme-rewrite.md b/changes/unreleased/feat-vscode-readme-rewrite.md deleted file mode 100644 index 0babf46f..00000000 --- a/changes/unreleased/feat-vscode-readme-rewrite.md +++ /dev/null @@ -1,5 +0,0 @@ -### Docs - -- **VS Code extension README rewritten for skeptical developers**: scientific framing throughout — every derived metric (focus score, flow detector, deep-work minute, struggle predictor, optimal hours, fatigue) now ships with its formula, hyperparameters, and a "what can go wrong" line. Added an explicit *What this is, and isn't* section, a *Validation status* table tracking each metric's evidence level (production / pilot / heuristic / descriptive-only), and a *Validation roadmap* describing the planned KSS / NASA-TLX / PVT / EEG-fatigue calibration channels. Replaced the marketing pain-points table with a per-API signal taxonomy and an architecture paragraph. -- **References section with verified citations**: nine entries with DOIs cross-checked via CrossRef and `doi.org` redirects — Csikszentmihalyi 1990 (flow), Klimesch 1999 (α/θ oscillations), Lubar 1991 (θ/β attention ratio), Barry et al. 2005 (caffeine confound), Newport 2016 (deep work), Roenneberg et al. 2003 (chronotypes), Hart & Staveland 1988 + Hart 2006 (NASA-TLX), Jap et al. 2009 (EEG fatigue index). Each reference linked back to the metric it grounds. -- **Repository metadata pointed at the right repo**: discovered `extensions/vscode/` is a git submodule of `vscode-neuroskill`, not a subdirectory of the monorepo. Reverted `repository.url`, `bugs.url`, `homepage`, `qna` to the submodule's actual repo so the marketplace links resolve. diff --git a/changes/unreleased/feat-vscode-readme-screenshots.md b/changes/unreleased/feat-vscode-readme-screenshots.md deleted file mode 100644 index 17eccd9c..00000000 --- a/changes/unreleased/feat-vscode-readme-screenshots.md +++ /dev/null @@ -1,9 +0,0 @@ -### UI - -- **Theme-aware sidebar screenshots in the README**: every screenshot now ships in two variants (`*-dark.png`, `*-light.png`) and is embedded via a `` element with `prefers-color-scheme` sources. GitHub serves the variant matching the reader's OS theme; the marketplace gets the dark fallback. Six states captured: in-flow, stuck, fatigued, low-focus / off-peak, daemon-disconnected, status-bar strip. -- **Sidebar mock library + Playwright generator**: HTML stubs in `extensions/vscode/media/preview/` render the sidebar webview offline (no daemon required). A shared `_styles.css` drives both themes via a `:root.light` override; `npm run screenshots` (Playwright) toggles `` between captures and writes 12 PNGs to `media/screenshots/`. -- **Marketplace README pipeline**: `scripts/build-marketplace-readme.mjs` rewrites every relative `media/...` path (including ``, which `vsce` doesn't touch) to absolute GitHub raw URLs at package time. The source `README.md` keeps relative paths for GitHub + offline viewing; the generated `.marketplace.readme.md` is what `vsce package --readme-path` ships. - -### Bugfixes - -- **Equal padding on screenshot edges**: viewport width was 360px while the body was 320px wide, leaving an asymmetric 40px right gutter. Matched viewport to body width so left and right gutters are now identical. diff --git a/changes/unreleased/feat-vscode-sidebar-light-mode.md b/changes/unreleased/feat-vscode-sidebar-light-mode.md deleted file mode 100644 index 936152bd..00000000 --- a/changes/unreleased/feat-vscode-sidebar-light-mode.md +++ /dev/null @@ -1,7 +0,0 @@ -### UI - -- **Sidebar webview now renders correctly in light themes** (`extensions/vscode/src/sidebar.ts`). Three theme-fragile spots fixed: - - `.metric-card` background switched from `--vscode-sideBar-background` (which is the same colour as the surrounding sidebar — cards collapsed into a faint border in light mode) to `--vscode-editorWidget-background`, with `--vscode-input-background` and a translucent grey as cascading fallbacks. - - `.ai-bar` track switched from `--vscode-panel-border` to `--vscode-progressBar-background` so the empty bar is visible against a white background. - - Row-divider opacity bumped from 0.08 to 0.18; added `:last-child { border-bottom: none }` on commit and AI-metric rows so the last row doesn't double up against the disclaimer footer. -- **State colours stay hard-coded** — red ring for stuck, green for flow, amber for warning. They're semantic, not chrome, and they read fine on both themes. diff --git a/changes/unreleased/feat-widget-a11y-i18n.md b/changes/unreleased/feat-widget-a11y-i18n.md deleted file mode 100644 index c9333273..00000000 --- a/changes/unreleased/feat-widget-a11y-i18n.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget accessibility and localization**. diff --git a/changes/unreleased/feat-widget-analysis.md b/changes/unreleased/feat-widget-analysis.md deleted file mode 100644 index 8a39433c..00000000 --- a/changes/unreleased/feat-widget-analysis.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Analysis widgets (Optimal Hours, Daily Report, Weekly Trend)**. diff --git a/changes/unreleased/feat-widget-biometrics.md b/changes/unreleased/feat-widget-biometrics.md deleted file mode 100644 index 55410d4f..00000000 --- a/changes/unreleased/feat-widget-biometrics.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Biometric widgets (Heart Rate, Cognitive Load, EEG Band Power, Sleep)**. diff --git a/changes/unreleased/feat-widget-brain-dashboard.md b/changes/unreleased/feat-widget-brain-dashboard.md deleted file mode 100644 index 0c167080..00000000 --- a/changes/unreleased/feat-widget-brain-dashboard.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Brain Dashboard widget (medium)**. diff --git a/changes/unreleased/feat-widget-calendar-mind.md b/changes/unreleased/feat-widget-calendar-mind.md deleted file mode 100644 index 3d9f6b99..00000000 --- a/changes/unreleased/feat-widget-calendar-mind.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Calendar Mind State widget (large)**. diff --git a/changes/unreleased/feat-widget-deep-links.md b/changes/unreleased/feat-widget-deep-links.md deleted file mode 100644 index 19dbca2c..00000000 --- a/changes/unreleased/feat-widget-deep-links.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget deep links (neuroskill:// URL scheme)**. diff --git a/changes/unreleased/feat-widget-dev-infra.md b/changes/unreleased/feat-widget-dev-infra.md deleted file mode 100644 index 92ed4ea0..00000000 --- a/changes/unreleased/feat-widget-dev-infra.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget development infrastructure**. diff --git a/changes/unreleased/feat-widget-focus-streak-session.md b/changes/unreleased/feat-widget-focus-streak-session.md deleted file mode 100644 index 54ab6bb9..00000000 --- a/changes/unreleased/feat-widget-focus-streak-session.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Core desktop widgets (Focus, Streak, Session, Break Timer)**. diff --git a/changes/unreleased/feat-widget-interactive.md b/changes/unreleased/feat-widget-interactive.md deleted file mode 100644 index 53de9a66..00000000 --- a/changes/unreleased/feat-widget-interactive.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Interactive widget buttons (macOS 14+)**. diff --git a/changes/unreleased/feat-widget-offline-cache.md b/changes/unreleased/feat-widget-offline-cache.md deleted file mode 100644 index c705a33d..00000000 --- a/changes/unreleased/feat-widget-offline-cache.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget offline data caching**. diff --git a/changes/unreleased/feat-widget-reload.md b/changes/unreleased/feat-widget-reload.md deleted file mode 100644 index 625a9ac9..00000000 --- a/changes/unreleased/feat-widget-reload.md +++ /dev/null @@ -1,3 +0,0 @@ -### Features - -- **Widget timeline reload on state changes**. diff --git a/changes/unreleased/feat-zuna-rs-mlx.md b/changes/unreleased/feat-zuna-rs-mlx.md deleted file mode 100644 index d71ce4ee..00000000 --- a/changes/unreleased/feat-zuna-rs-mlx.md +++ /dev/null @@ -1,9 +0,0 @@ -### Features - -- **zuna-rs 0.1.4**: updated from 0.1.3 with native MLX backend for EEG embedding inference. -- **`embed-zuna-mlx` feature**: new `ZunaMlxState` struct with `ZunaEncoder`. Adds `load_zuna_mlx()` and `encode_zuna_mlx()` functions matching the existing GPU/CPU variants. -- **Load priority**: when user selects Auto or MLX, encoder loading tries MLX first, then GPU f16, then GPU f32, then CPU. - -### i18n - -- Added `settings.inferenceDeviceAuto`, `settings.inferenceDeviceAutoDesc`, `settings.inferenceDeviceMlx`, `settings.inferenceDeviceMlxDesc` to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). diff --git a/changes/unreleased/fix-daemon-deadlocks-and-correctness.md b/changes/unreleased/fix-daemon-deadlocks-and-correctness.md deleted file mode 100644 index 10da19d0..00000000 --- a/changes/unreleased/fix-daemon-deadlocks-and-correctness.md +++ /dev/null @@ -1,9 +0,0 @@ -### Bugfixes - -- **Deadlock in daily-report endpoint**: `daily_brain_report` held the database mutex while calling `productivity_score`, which re-acquires the same mutex. Scoped the lock to drop before the nested call. -- **Deadlock in struggle-predict endpoint**: same pattern in `predict_struggle` — held mutex while calling `get_recent_files`. -- **`overall_focus` returned `-0.0`**: always wrapped in `Some` even with no EEG data. Now returns `null` when no focus samples exist. -- **`best_period` arbitrary without EEG**: picked the first SQL result when all `avg_focus` were `None`. Now returns empty string when no EEG data to compare. -- **`weekly_avg_deep_mins` returned `-0.0`**: divided by hardcoded 7 regardless of actual days. Now divides by actual day count, returns `0.0` when empty. -- **Notification text ugly without EEG**: daily report notification showed "Best: . Score: 25. Focus: 0." — now conditionally includes only available fields. -- **Brain endpoints return HTTP 200 on errors**: all 18 brain handlers silently returned `null` with 200 OK when the activity store was offline or a query failed. Refactored to use `run_query` helper that returns 503 (db_unavailable) or 500 (task_error) with structured `ApiError` JSON. diff --git a/changes/unreleased/fix-daemon-performance.md b/changes/unreleased/fix-daemon-performance.md deleted file mode 100644 index 8663c739..00000000 --- a/changes/unreleased/fix-daemon-performance.md +++ /dev/null @@ -1,8 +0,0 @@ -### Performance - -- **`open_readonly` for read-only handlers**: added `ActivityStore::open_readonly()` that skips 15+ ALTER TABLE migrations and opens in read-only mode. Switched 38+ HTTP handlers and 3 background tasks from `open` to `open_readonly`. -- **`PRAGMA busy_timeout=5000`**: added to `init_wal_pragmas` and `util::open_readonly` so all database connections (activity, labels, screenshots, EEG embeddings) wait up to 5 s on lock contention instead of failing immediately with SQLITE_BUSY. -- **Composite indexes**: added `(seen_at, file_path)` and `(seen_at, project)` indexes on `file_interactions` for queries that filter by time range and group by path or project. -- **Lock consolidation**: `productivity_score` reduced from 3 separate mutex acquisitions to 1 via internal `_q` query helpers. `weekly_digest` reduced from 12 to 4 locks. Introduced `daily_summary_q`, `context_switch_rate_q`, `get_focus_sessions_in_range_q` static methods that take `&Connection` directly. -- **`PRAGMA optimize` after pruning**: runs after hourly retention pruning to keep query planner statistics fresh. -- **Batch label lookup in interactive search**: replaced 15 per-epoch `get_labels_near` calls (each opening a new DB connection) with a single `get_labels_near_batch` call per text label, then in-memory filtering. Reduces search DB round-trips from ~28 to ~16. diff --git a/changes/unreleased/fix-daemon-reliability.md b/changes/unreleased/fix-daemon-reliability.md deleted file mode 100644 index 075d6b80..00000000 --- a/changes/unreleased/fix-daemon-reliability.md +++ /dev/null @@ -1,9 +0,0 @@ -### Bugfixes - -- **Worker thread panic recovery with auto-restart**: all 4 activity worker threads (poller, input monitor, file watcher, clipboard monitor) now wrapped in `catch_unwind` via `spawn_resilient`. On panic, the worker logs the error and restarts after 5 s with freshly cloned `AppState` and `Arc`. -- **osascript timeout**: all `osascript` calls (active window poll, secondary windows, clipboard monitor) now use a 3-second timeout via `run_osascript` helper. Previously, a hung app could block the poller thread indefinitely, silently killing all activity tracking. -- **Daily report catch-up on late startup**: if the daemon starts after 18:00 local, the daily brain report fires on the first tick instead of waiting up to 1 hour for the hourly check. -- **WAL checkpoint on shutdown**: daemon now runs `PRAGMA optimize` on the activity database during graceful shutdown, ensuring the WAL is checkpointed and the database is clean for next start. -- **Missing WAL pragmas on EEG embedding store**: `day_store.rs` opened connections without `init_wal_pragmas`, missing both WAL mode and `busy_timeout`. -- **Retention pruning for new tables**: added `prune_terminal_commands`, `prune_ai_events`, `prune_zone_switches`, `prune_layout_snapshots` — wired into the hourly maintenance cycle so these tables don't grow unbounded. -- **TOCTOU race in calibration settings**: concurrent profile CRUD could lose writes. Added `modify_settings_blocking()` helper that runs under a global mutex on tokio's blocking thread pool — serializes read-modify-write without blocking the async runtime. diff --git a/changes/unreleased/fix-eeg-pipeline.md b/changes/unreleased/fix-eeg-pipeline.md deleted file mode 100644 index 6f8f7ca2..00000000 --- a/changes/unreleased/fix-eeg-pipeline.md +++ /dev/null @@ -1,5 +0,0 @@ -### Bugfixes - -- **Duration-averaged EEG**: `FileSnapshot` now samples EEG focus/mood every 5 s (at each edit-chunk tick) and writes the average to `file_interactions` at finalize, replacing the single snapshot captured at file-switch time. Falls back to the initial snapshot if no samples were collected (`COALESCE`). -- **EEG mood/focus sample count mismatch**: both `FileSnapshot` and `build_focus_sessions` used a single counter for focus and mood samples, inflating mood averages when they arrived independently. Split into separate `focus_count` and `mood_count`. -- **EEG ghost data after disconnect**: `latest_bands` was not cleared when an EEG device disconnected, causing stale focus/mood values to persist in reports and the activity pipeline. Now cleared on all 3 disconnect paths (idle timeout, stream end, explicit disconnect). diff --git a/changes/unreleased/fix-frontend-leaks.md b/changes/unreleased/fix-frontend-leaks.md deleted file mode 100644 index 93a8abf6..00000000 --- a/changes/unreleased/fix-frontend-leaks.md +++ /dev/null @@ -1,9 +0,0 @@ -### Bugfixes - -- **InteractiveGraph3D event listener leaks**: `pointerdown` and `dblclick` listeners on the WebGL canvas were never removed in `onDestroy`. Extracted into named refs with cleanup. -- **UmapViewer3D event listener leaks**: 4 canvas event listeners (pointermove, pointerleave, pointerdown, pointerup) added as anonymous functions were never removed on unmount. Extracted into named module-level refs with removal in `onDestroy`. -- **ActivityTab brain polling leak**: `startBrainPolling()` was called on mount but `stopBrainPolling()` was never called on destroy, leaving a 30-second interval running after leaving the tab. -- **Brain polling stopped by peer component**: `stopBrainPolling()` unconditionally killed the interval even when other components still needed it. Added reference counting so polling only stops when the last consumer unmounts. -- **Memory leak in terminal_command_end labeling**: `Box::leak` was used to format non-zero exit codes in EEG auto-labeling, permanently leaking memory for every failed command. Replaced with owned `String`. -- **Screenshot encode failures untracked**: `encode_webp` failures (disk full, I/O error) silently continued without incrementing the `capture_errors` counter. Now counted so metrics reflect actual failures. -- **401 auto-retry**: daemon HTTP client now retries once with a fresh token on 401 (stale token after daemon restart) instead of failing immediately. diff --git a/changes/unreleased/fix-macos-libusb-static-link.md b/changes/unreleased/fix-macos-libusb-static-link.md deleted file mode 100644 index 8a250c88..00000000 --- a/changes/unreleased/fix-macos-libusb-static-link.md +++ /dev/null @@ -1,3 +0,0 @@ -### Build - -- **macOS: link libusb statically into `skill-daemon`**: vendored libusb via `rusb`'s `vendored` feature in `crates/skill-devices`. The `antneuro` USB driver pulls in `rusb` → `libusb1-sys`, which by default uses `pkg-config` to find a system libusb-1.0.dylib. On the macOS-26 release runner that resolved to Homebrew's `/opt/homebrew/opt/libusb/lib/libusb-1.0.0.dylib`, so end users without Homebrew hit a launch-time `dyld: Library not loaded` error. Cargo feature unification now flips `libusb1-sys/vendored`, which compiles libusb from source and links `libusb-vendored.a` into the binary — verified by 105 `_libusb_*` text symbols present in the release binary and zero libusb references in `otool -L`. diff --git a/changes/unreleased/fix-security.md b/changes/unreleased/fix-security.md deleted file mode 100644 index 3628e310..00000000 --- a/changes/unreleased/fix-security.md +++ /dev/null @@ -1,5 +0,0 @@ -### Bugfixes - -- **Path traversal in `delete_session`**: `csv_path` parameter was passed unsanitized to `fs::remove_file`. Now validates the path is within `skill_dir` via canonicalize + starts_with. -- **Timing-vulnerable token comparison**: default bearer token checks in auth middleware used `==` (variable-time). Switched to `constant_time_eq` for both in-memory and on-disk token paths. -- **Unbounded CSV memory in EXG loader**: loading a CSV with millions of rows would allocate unlimited RAM. Added a 4M row cap (~4.3 hours at 256 Hz). diff --git a/changes/unreleased/fix-timezone.md b/changes/unreleased/fix-timezone.md deleted file mode 100644 index ef571dfd..00000000 --- a/changes/unreleased/fix-timezone.md +++ /dev/null @@ -1,6 +0,0 @@ -### Bugfixes - -- **Timezone-wrong period classification**: `daily_brain_report`, `hourly_edit_heatmap`, and `optimal_hours` used `seen_at % 86400` (UTC hour) instead of local hour. Daily report now uses `seen_at - day_start`; heatmap and optimal-hours accept a timezone offset computed server-side. -- **UTC midnight in all clients**: search page, CLI (`cmdActivity`, `cmdBrain`), VS Code extension, and Swift widget all computed `day_start` as UTC midnight instead of local midnight. Fixed to use `Date.setHours(0,0,0,0)` (JS/TS) and `Calendar.startOfDay` (Swift). -- **Widget empty daily-report body**: `DaemonClient.fetchDailyReport()` sent an empty POST body; the endpoint requires `dayStart`. Now sends local midnight. -- **Widget UTC sleep/calendar endpoints**: `fetchSleep()` and `fetchCalendarEvents()` used `% 86400` UTC approximations. Now use `Calendar.current` for correct local time boundaries. diff --git a/crates/iroh-example-client/Cargo.toml b/crates/iroh-example-client/Cargo.toml index 31e1f0fc..d0ef8f64 100644 --- a/crates/iroh-example-client/Cargo.toml +++ b/crates/iroh-example-client/Cargo.toml @@ -6,8 +6,8 @@ license = "GPL-3.0-only" description = "End-to-end example: create TOTP, spin up iroh endpoint, generate OTP, register with Skill iroh server" [dependencies] -iroh = "0.97" -iroh-base = "0.97" +iroh = "1.0.0-rc.0" +iroh-base = "1.0.0-rc.0" skill-iroh = { path = "../skill-iroh" } tokio = { version = "1", features = ["full"] } totp-rs = "5.7" diff --git a/crates/iroh_test_client/Cargo.toml b/crates/iroh_test_client/Cargo.toml index 9a4505db..78001b0c 100644 --- a/crates/iroh_test_client/Cargo.toml +++ b/crates/iroh_test_client/Cargo.toml @@ -5,8 +5,8 @@ edition = "2021" license = "GPL-3.0-only" [dependencies] -iroh = { version = "0.97", package = "iroh" } -iroh-base = "0.97" +iroh = { version = "1.0.0-rc.0", package = "iroh" } +iroh-base = "1.0.0-rc.0" rand = "0.10.1" totp-rs = "5.7" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/skill-commands/src/lib.rs b/crates/skill-commands/src/lib.rs index 1d272de5..4bc4aaf6 100644 --- a/crates/skill-commands/src/lib.rs +++ b/crates/skill-commands/src/lib.rs @@ -38,7 +38,7 @@ pub mod graph; pub use graph::{dot_edge_label, dot_esc, dot_node_label, generate_dot, generate_svg, generate_svg_3d, SvgLabels}; // Re-export shared utilities so downstream crates keep compiling. -pub use skill_data::util::{fmt_unix_utc, ts_to_unix, unix_to_ts, MutexExt}; +pub use skill_data::util::{fmt_unix_utc, ts_to_unix, unix_to_ts, DualTimestampRange, MutexExt}; /// Shared, optionally-ready global HNSW index. /// @@ -244,15 +244,21 @@ struct RawEmb { embedding: Vec, } -/// Read every embedding in [start_ts, end_ts] from a single day's SQLite. -fn read_embeddings_in_range(db_path: &Path, start_ts: i64, end_ts: i64) -> Vec { - read_embeddings_in_range_filtered(db_path, start_ts, end_ts, None) +/// Read every embedding in [start_utc, end_utc] from a single day's SQLite. +/// +/// Uses [`DualTimestampRange`] so it matches all three timestamp formats that +/// may appear in the `embeddings` table: +/// - Unix milliseconds (13 digits) +/// - `YYYYMMDDHHmmss` (14 digits, pre-Apr 2026) +/// - `YYYYMMDDHHmmss × 1000` (17 digits, Apr 2026+) +fn read_embeddings_in_range(db_path: &Path, start_utc: u64, end_utc: u64) -> Vec { + read_embeddings_in_range_filtered(db_path, start_utc, end_utc, None) } fn read_embeddings_in_range_filtered( db_path: &Path, - start_ts: i64, - end_ts: i64, + start_utc: u64, + end_utc: u64, device_filter: Option<&str>, ) -> Vec { let conn = match skill_data::util::open_readonly(db_path) { @@ -263,30 +269,46 @@ fn read_embeddings_in_range_filtered( } }; + let r = skill_data::util::DualTimestampRange::from_unix_secs(start_utc, end_utc); + let ts_where = skill_data::util::DualTimestampRange::WHERE_CLAUSE; + let (sql, params): (String, Vec>) = if let Some(dev) = device_filter { ( - "SELECT hnsw_id, timestamp, eeg_embedding \ - FROM embeddings \ - WHERE timestamp BETWEEN ?1 AND ?2 \ - AND length(eeg_embedding) >= 4 \ - AND device_name = ?3 \ - ORDER BY timestamp" - .into(), + format!( + "SELECT hnsw_id, timestamp, eeg_embedding \ + FROM embeddings \ + WHERE ({ts_where}) \ + AND length(eeg_embedding) >= 4 \ + AND device_name = ?7 \ + ORDER BY timestamp" + ), vec![ - Box::new(start_ts) as Box, - Box::new(end_ts), + Box::new(r.unix_ms_start) as Box, + Box::new(r.unix_ms_end), + Box::new(r.dt14_start), + Box::new(r.dt14_end), + Box::new(r.dt17_start), + Box::new(r.dt17_end), Box::new(dev.to_string()), ], ) } else { ( - "SELECT hnsw_id, timestamp, eeg_embedding \ - FROM embeddings \ - WHERE timestamp BETWEEN ?1 AND ?2 \ - AND length(eeg_embedding) >= 4 \ - ORDER BY timestamp" - .into(), - vec![Box::new(start_ts) as Box, Box::new(end_ts)], + format!( + "SELECT hnsw_id, timestamp, eeg_embedding \ + FROM embeddings \ + WHERE ({ts_where}) \ + AND length(eeg_embedding) >= 4 \ + ORDER BY timestamp" + ), + vec![ + Box::new(r.unix_ms_start) as Box, + Box::new(r.unix_ms_end), + Box::new(r.dt14_start), + Box::new(r.dt14_end), + Box::new(r.dt17_start), + Box::new(r.dt17_end), + ], ) }; @@ -315,8 +337,24 @@ fn read_embeddings_in_range_filtered( } /// Derive the `YYYYMMDD` date string from a `YYYYMMDDHHmmss` timestamp integer. +/// Extract a `YYYYMMDD` directory name from any embeddings-table timestamp. +/// +/// Handles all three historical formats: +/// - 17-digit `YYYYMMDDHHmmss × 1000` (e.g. `20260427034308000`) → divide by 10^9 +/// - 14-digit `YYYYMMDDHHmmss` (e.g. `20260427034308`) → divide by 10^6 +/// - 13-digit Unix milliseconds (e.g. `1777362376000`) → convert via calendar fn date_from_ts(ts: i64) -> String { - format!("{}", ts / 1_000_000) + let digits = if ts > 0 { (ts as f64).log10() as u32 + 1 } else { 0 }; + match digits { + 17 => format!("{}", ts / 1_000_000_000), // YYYYMMDDHHmmss×1000 → YYYYMMDD + 14 => format!("{}", ts / 1_000_000), // YYYYMMDDHHmmss → YYYYMMDD + _ => { + // Unix milliseconds: convert to Unix secs, then to YYYYMMDDHHmmss, take date part. + let secs = (ts.max(0) / 1000) as u64; + let dt14 = skill_data::util::unix_to_ts(secs); + format!("{}", dt14 / 1_000_000) + } + } } /// Convert a database timestamp (ms) to Unix seconds. @@ -464,12 +502,10 @@ pub fn search_embeddings_in_range_for( global_index: GlobalIndexHandle, model_backend: &str, ) -> SearchResult { - let start_ts = (start_utc as i64) * 1000; - let end_ts = (end_utc as i64) * 1000; let labels_db = skill_dir.join(LABELS_FILE); let date_dirs = list_date_dirs(skill_dir); - // ── Collect query embeddings from days that overlap [start_ts, end_ts] ──── + // ── Collect query embeddings from days that overlap [start_utc, end_utc] ──── // Store index into `date_dirs` to avoid cloning String/PathBuf per embedding. let mut query_embs: Vec<(usize, RawEmb)> = Vec::new(); for (dd_idx, (date, dir)) in date_dirs.iter().enumerate() { @@ -477,7 +513,7 @@ pub fn search_embeddings_in_range_for( if !db_path.exists() { continue; } - let embs = read_embeddings_in_range(&db_path, start_ts, end_ts); + let embs = read_embeddings_in_range(&db_path, start_utc, end_utc); if !embs.is_empty() { eprintln!("[search] {} query embs from {}", embs.len(), date); } @@ -648,8 +684,6 @@ pub fn stream_search_inner_for( emit: &dyn Fn(SearchProgress), model_backend: &str, ) { - let start_ts = (start_utc as i64) * 1000; - let end_ts = (end_utc as i64) * 1000; let labels_db = skill_dir.join(LABELS_FILE); let date_dirs = list_date_dirs(skill_dir); @@ -681,7 +715,7 @@ pub fn stream_search_inner_for( if !db_path.exists() { continue; } - let embs = read_embeddings_in_range_filtered(&db_path, start_ts, end_ts, device_filter); + let embs = read_embeddings_in_range_filtered(&db_path, start_utc, end_utc, device_filter); let _ = date; // used only for db_path for emb in embs { query_embs.push((dd_idx, emb)); @@ -1460,7 +1494,7 @@ mod tests { #[test] fn date_from_ts_extracts_date_prefix() { - // ts format is YYYYMMDDHHmmss — dividing by 1_000_000 gives YYYYMMDD + // 14-digit YYYYMMDDHHmmss → divide by 10^6 assert_eq!(date_from_ts(20260414143000), "20260414"); } @@ -1469,6 +1503,21 @@ mod tests { assert_eq!(date_from_ts(19700101000000), "19700101"); } + #[test] + fn date_from_ts_17digit() { + // 17-digit YYYYMMDDHHmmss×1000 (current stored format) → divide by 10^9 + assert_eq!(date_from_ts(20260427034308000), "20260427"); + } + + #[test] + fn date_from_ts_unix_ms() { + // Unix milliseconds (13 digits) → calendar conversion + // 1777362376000 ms = 2026-04-26 ... UTC + let result = date_from_ts(1777362376000); + assert!(result.starts_with("2026"), "expected 2026 date, got {result}"); + assert_eq!(result.len(), 8, "YYYYMMDD must be 8 chars, got {result}"); + } + // ── ts_ms_to_unix ──────────────────────────────────────────────────── #[test] diff --git a/crates/skill-constants/src/lib.rs b/crates/skill-constants/src/lib.rs index 4727b3ea..edeae5b0 100644 --- a/crates/skill-constants/src/lib.rs +++ b/crates/skill-constants/src/lib.rs @@ -15,6 +15,9 @@ //! use skill_constants::prelude::*; //! ``` +#[macro_use] +pub mod log_macros; + // ── Poison-recovering Mutex helper ──────────────────────────────────────────── /// Extension trait for `std::sync::Mutex` that recovers from poison. @@ -66,6 +69,9 @@ pub mod prelude { DEFAULT_HP_HZ, DEFAULT_LP_HZ, DEFAULT_NOTCH_BW_HZ, + EEGDINO_DEFAULT_VARIANT, + EEGDINO_HF_REPO, + EEGDINO_VARIANTS, // Hardware EEG_CHANNELS, EMBEDDING_EPOCH_SAMPLES, @@ -511,6 +517,19 @@ pub const LUNA_VARIANTS: [(&str, &str); 3] = [ /// Default LUNA model variant. pub const LUNA_DEFAULT_VARIANT: &str = "base"; +/// HuggingFace repository identifier for EEG-DINO safetensors weights. +pub const EEGDINO_HF_REPO: &str = "eugenehp/eegdino"; + +/// Available EEG-DINO model size variants: `(variant_name, weights_filename)`. +pub const EEGDINO_VARIANTS: [(&str, &str); 3] = [ + ("small", "eeg_dino_small.safetensors"), + ("medium", "eeg_dino_medium.safetensors"), + ("large", "eeg_dino_large.safetensors"), +]; + +/// Default EEG-DINO model variant. +pub const EEGDINO_DEFAULT_VARIANT: &str = "small"; + /// Per-variant LUNA model hyperparameters: `(variant, embed_dim, num_queries, depth, num_heads)`. /// /// These override the generic `config.json` defaults so that each checkpoint @@ -588,6 +607,15 @@ pub const LABEL_CONTEXT_INDEX_FILE: &str = "label_context_index.hnsw"; /// HNSW index for EEG embeddings of label epochs. pub const LABEL_EEG_INDEX_FILE: &str = "label_eeg_index.hnsw"; +/// TurboVec index for text embeddings of label text. +pub const LABEL_TEXT_TURBOVEC_INDEX_FILE: &str = "label_text_index.tvim"; + +/// TurboVec index for text embeddings of label context. +pub const LABEL_CONTEXT_TURBOVEC_INDEX_FILE: &str = "label_context_index.tvim"; + +/// TurboVec index for EEG embeddings of label epochs. +pub const LABEL_EEG_TURBOVEC_INDEX_FILE: &str = "label_eeg_index.tvim"; + /// HNSW index for code context embeddings (terminal commands, conversations, file interactions). /// Separate from the label index to avoid diluting EEG-focused searches. pub const CODE_CONTEXT_INDEX_FILE: &str = "code_context_index.hnsw"; diff --git a/crates/skill-constants/src/log_macros.rs b/crates/skill-constants/src/log_macros.rs new file mode 100644 index 00000000..23960de1 --- /dev/null +++ b/crates/skill-constants/src/log_macros.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com + +/// Conditional log line for a subsystem (`log_enabled` + `write_log` function paths). +#[macro_export] +macro_rules! subsystem_log { + ($log_enabled:path, $write_log:path, $tag:expr, $($arg:tt)*) => { + if $log_enabled() { + $write_log($tag, &format!($($arg)*)); + } + }; +} diff --git a/crates/skill-daemon-state/Cargo.toml b/crates/skill-daemon-state/Cargo.toml index 3274e969..071f3b1c 100644 --- a/crates/skill-daemon-state/Cargo.toml +++ b/crates/skill-daemon-state/Cargo.toml @@ -6,21 +6,37 @@ license = "GPL-3.0-only" description = "Shared state types for skill-daemon — extracted for parallel compilation" [features] +# Text-embeddings backend selection. `fastembed` (ONNX via ort+rten, +# ~30 MB binary) ships on by default for backwards compatibility; `rlx` +# is the pure-Rust alternative (RlxBertModel / RlxNomicModel from +# rlx-embed) — drop-in for the same set of BGE / Nomic / E5 models. +# To build without fastembed entirely: +# --no-default-features --features llm,text-embeddings-rlx default = ["llm"] llm = ["skill-llm/llm"] +text-embeddings-fastembed = ["dep:fastembed"] +text-embeddings-rlx = ["dep:hf-hub", "dep:rlx", "dep:rlx-models", "dep:tokenizers"] +text-embeddings-rlx-metal = ["text-embeddings-rlx", "rlx?/metal", "rlx?/blas-accelerate", "rlx-models?/metal"] +text-embeddings-rlx-cuda = ["text-embeddings-rlx", "rlx?/cuda", "rlx-models?/cuda"] +text-embeddings-rlx-rocm = ["text-embeddings-rlx", "rlx?/rocm", "rlx-models?/rocm"] +text-embeddings-rlx-wgpu = ["text-embeddings-rlx", "rlx?/gpu", "rlx-models?/gpu"] [dependencies] anyhow = { workspace = true } thiserror = { workspace = true } base64 = { workspace = true } dirs = "6" -fastembed = { version = "5.13.0", features = ["ort-download-binaries-native-tls"] } +fastembed = { version = "5.13.0", features = ["ort-download-binaries-native-tls"], optional = true } +hf-hub = { version = "0.5", default-features = false, features = ["ureq"], optional = true } hex = "0.4" rand = "0.10" +rlx = { workspace = true, optional = true, features = ["cpu"] } +rlx-models = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10" tokio = { version = "1", features = ["sync", "rt-multi-thread", "macros", "time"] } +tokenizers = { version = "0.22", default-features = false, features = ["onig"], optional = true } tracing = "0.1" skill-constants = { path = "../skill-constants" } @@ -29,7 +45,7 @@ skill-data = { path = "../skill-data" } skill-devices = { path = "../skill-devices" } skill-eeg = { path = "../skill-eeg" } skill-iroh = { path = "../skill-iroh" } -skill-label-index = { path = "../skill-label-index" } +skill-label-index = { path = "../skill-label-index", features = ["turboquant-index"] } skill-llm = { path = "../skill-llm" } skill-lsl = { path = "../skill-lsl" } skill-settings = { path = "../skill-settings" } diff --git a/crates/skill-daemon-state/src/bin/bench_text_embeddings.rs b/crates/skill-daemon-state/src/bin/bench_text_embeddings.rs new file mode 100644 index 00000000..03cc7769 --- /dev/null +++ b/crates/skill-daemon-state/src/bin/bench_text_embeddings.rs @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Benchmark FastEmbed/ORT vs RLX text embeddings. +//! +//! Example: +//! ```sh +//! cargo run --release -p skill-daemon-state --features text-embeddings-rlx-metal \ +//! --bin bench_text_embeddings -- \ +//! --model nomic-ai/nomic-embed-text-v1.5 --backends all --batch-sizes 1,8,32 +//! ``` + +use anyhow::{anyhow, Result}; +use skill_daemon_state::text_embedder::{SharedTextEmbedder, TextEmbeddingBackend}; +use std::time::Instant; + +#[derive(Debug, Clone)] +struct Args { + model: String, + backends: Backends, + rlx_device: String, + rlx_max_seq: usize, + batch_sizes: Vec, + warmup: usize, + runs: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Backends { + All, + FastEmbed, + Rlx, +} + +struct BenchRow { + backend: &'static str, + batch: usize, + load_ms: f64, + mean_ms: f64, + docs_s: f64, + dim: usize, +} + +fn main() -> Result<()> { + let args = parse_args()?; + println!("# Text embedding backend benchmark"); + println!("model: {}", args.model); + println!( + "batch_sizes: {:?} - runs: {} - warmup: {} - rlx_device: {} - rlx_max_seq: {}", + args.batch_sizes, args.runs, args.warmup, args.rlx_device, args.rlx_max_seq + ); + + let corpus = sample_texts(*args.batch_sizes.iter().max().unwrap_or(&1)); + let mut rows = Vec::new(); + + if matches!(args.backends, Backends::All | Backends::FastEmbed) { + rows.extend(run_backend(&args, TextEmbeddingBackend::FastEmbed, &corpus)?); + } + if matches!(args.backends, Backends::All | Backends::Rlx) { + rows.extend(run_backend(&args, TextEmbeddingBackend::Rlx, &corpus)?); + } + + print_rows(&rows); + Ok(()) +} + +fn run_backend(args: &Args, backend: TextEmbeddingBackend, corpus: &[String]) -> Result> { + let embedder = SharedTextEmbedder::new(); + embedder.set_model_code(&args.model); + embedder.set_backend(backend); + embedder.set_rlx_device(&args.rlx_device); + embedder.set_rlx_max_seq(args.rlx_max_seq); + + let t_load = Instant::now(); + if !embedder.reload() { + return Err(anyhow!( + "failed to load {} backend for {}", + backend.as_str(), + args.model + )); + } + let load_ms = t_load.elapsed().as_secs_f64() * 1000.0; + + let mut rows = Vec::new(); + for &batch in &args.batch_sizes { + let texts: Vec<&str> = corpus.iter().take(batch).map(String::as_str).collect(); + for _ in 0..args.warmup { + let _ = embedder.embed_batch(texts.clone()); + } + + let mut times = Vec::with_capacity(args.runs); + let mut dim = 0usize; + for _ in 0..args.runs { + let t = Instant::now(); + let vecs = embedder + .embed_batch(texts.clone()) + .ok_or_else(|| anyhow!("{} embedding failed at batch {batch}", backend.as_str()))?; + let ms = t.elapsed().as_secs_f64() * 1000.0; + dim = vecs.first().map_or(0, Vec::len); + times.push(ms); + } + + let mean_ms = times.iter().sum::() / times.len().max(1) as f64; + rows.push(BenchRow { + backend: backend.as_str(), + batch, + load_ms, + mean_ms, + docs_s: batch as f64 / (mean_ms / 1000.0), + dim, + }); + } + + Ok(rows) +} + +fn parse_args() -> Result { + let mut model = "nomic-ai/nomic-embed-text-v1.5".to_string(); + let mut backends = Backends::All; + let mut rlx_device = if cfg!(target_os = "macos") { "metal" } else { "cpu" }.to_string(); + let mut rlx_max_seq = 512usize; + let mut batch_sizes = vec![1, 8, 32]; + let mut warmup = 2usize; + let mut runs = 10usize; + + let argv: Vec = std::env::args().skip(1).collect(); + let mut i = 0usize; + while i < argv.len() { + let key = &argv[i]; + let mut value = || -> Result { + i += 1; + argv.get(i).cloned().ok_or_else(|| anyhow!("missing value for {key}")) + }; + match key.as_str() { + "--model" => model = value()?, + "--backends" => { + backends = match value()?.as_str() { + "all" => Backends::All, + "fastembed" | "ort" => Backends::FastEmbed, + "rlx" => Backends::Rlx, + other => return Err(anyhow!("--backends must be all|fastembed|rlx, got {other}")), + }; + } + "--rlx-device" => rlx_device = value()?, + "--rlx-max-seq" => rlx_max_seq = value()?.parse()?, + "--batch-sizes" => { + batch_sizes = value()? + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::parse) + .collect::, _>>()?; + } + "--warmup" => warmup = value()?.parse()?, + "--runs" => runs = value()?.parse()?, + "--help" | "-h" => { + print_usage(); + std::process::exit(0); + } + other => return Err(anyhow!("unknown flag {other}")), + } + i += 1; + } + + Ok(Args { + model, + backends, + rlx_device, + rlx_max_seq, + batch_sizes, + warmup, + runs, + }) +} + +fn sample_texts(n: usize) -> Vec { + let seeds = [ + "Working on a Rust refactor with local model inference.", + "Reading documentation about Apple Metal graph execution.", + "Debugging a semantic search issue in the label index.", + "Reviewing EEG focus metrics during a coding session.", + "Comparing ONNX Runtime and RLX embedding throughput.", + "Writing a concise summary for a pull request.", + "Investigating terminal activity embeddings for recent commands.", + "Planning a benchmark for Qwen prompt prefill and decode.", + ]; + (0..n) + .map(|i| format!("{} Sample #{i}.", seeds[i % seeds.len()])) + .collect() +} + +fn print_rows(rows: &[BenchRow]) { + println!(); + println!("| backend | batch | load ms | mean ms | docs/s | dim |"); + println!("|---|---:|---:|---:|---:|---:|"); + for row in rows { + println!( + "| {} | {} | {:.1} | {:.2} | {:.1} | {} |", + row.backend, row.batch, row.load_ms, row.mean_ms, row.docs_s, row.dim + ); + } +} + +fn print_usage() { + eprintln!( + "Usage: bench_text_embeddings [--model HF_REPO] [--backends all|fastembed|rlx] \ + [--rlx-device metal] [--rlx-max-seq 512] [--batch-sizes 1,8,32] \ + [--warmup 2] [--runs 10]" + ); +} diff --git a/crates/skill-daemon-state/src/state.rs b/crates/skill-daemon-state/src/state.rs index dc1c7898..3c07177d 100644 --- a/crates/skill-daemon-state/src/state.rs +++ b/crates/skill-daemon-state/src/state.rs @@ -45,6 +45,18 @@ pub struct IdleReembedStatus { pub done: u64, /// Current day directory being processed (e.g. "20260415"). pub current_day: String, + /// Whether the loop is deferring work because system memory usage exceeds + /// `ReembedConfig::max_resident_memory_percent`. UI surfaces this so the + /// user knows why a run that "should" be active isn't. + pub memory_throttled: bool, + /// Last sampled system memory usage percent (used / total). 0 when unread. + pub memory_percent: u8, + /// Seconds remaining on the "no-progress" backoff. 0 when no backoff is + /// active. UI surfaces this so the user knows why the loop appears idle + /// even though epochs still need embedding. + pub backoff_secs_remaining: u64, + /// Human-readable reason for the current backoff (empty when none). + pub backoff_reason: String, } /// Shared application state threaded through all axum handlers. @@ -158,6 +170,35 @@ pub struct AppState { /// in `~/.skill/validation.sqlite`; this struct only holds ephemeral state /// that should reset on daemon restart. pub validation_runtime: Arc>, + /// Heartbeat registry for daemon background tasks. + /// + /// Keyed by the static task id used in `/v1/activity` (e.g. "device-scanner", + /// "idle-reembed"). Each tick a task records `last_tick_unix_ms` and + /// `last_duration_ms`, so the activity panel can show "last ran 2s ago" + /// without per-tick logging or polling. Adding the key here is also what + /// makes a new background loop visible to the manifest — preventing the + /// drift we'd otherwise get when someone adds a worker but forgets to + /// edit the `/v1/activity` route. + pub task_heartbeats: Arc>>, +} + +/// Per-task heartbeat record. Updated by background workers; read by +/// `/v1/activity` and the `activity-state` WebSocket broadcast. +#[derive(Clone, Default, Debug, serde::Serialize)] +pub struct TaskHeartbeat { + /// Unix-ms timestamp of the most recent tick. `0` until the first tick. + pub last_tick_unix_ms: u64, + /// Duration of the most recent tick in ms. Useful for spotting tasks + /// that are quietly slow (long serial-port enumerations, embed batches). + pub last_duration_ms: u64, + /// Number of ticks recorded since daemon start. + pub tick_count: u64, + /// Last time we broadcast an `activity-state` WS event for this task. + /// Internal — used to enforce `min_broadcast_interval_ms` and *not* + /// serialised to the API (renames in JSON would be a no-op anyway since + /// callers don't need this). + #[serde(skip)] + pub last_broadcast_unix_ms: u64, } /// Observable state of the daemon-driven calibration session. @@ -265,13 +306,28 @@ impl AppState { exg_download_cancel: Arc::new(AtomicBool::new(false)), idle_reembed_cancel: Arc::new(AtomicBool::new(false)), idle_reembed_state: Arc::new(Mutex::new(IdleReembedStatus::default())), - label_index: Arc::new(LabelIndexState::new()), + label_index: { + let idx = LabelIndexState::new(); + if let Some(backend) = skill_label_index::LabelIndexBackend::parse(&settings.label_index_backend) { + idx.set_preferred_backend(backend); + } + Arc::new(idx) + }, reconnect: Arc::new(Mutex::new(ReconnectState::default())), text_embedder: { let te = SharedTextEmbedder::new(); if !settings.text_embedding_model.is_empty() { te.set_model_code(&settings.text_embedding_model); } + if let Some(backend) = + crate::text_embedder::TextEmbeddingBackend::parse(&settings.text_embedding_backend) + { + te.set_backend(backend); + } + if !settings.text_embedding_rlx_device.is_empty() { + te.set_rlx_device(&settings.text_embedding_rlx_device); + } + te.set_rlx_max_seq(settings.text_embedding_rlx_max_seq); te }, iroh_logs_enabled: Arc::new(AtomicBool::new(settings.iroh_logs)), @@ -282,6 +338,55 @@ impl AppState { calibration_phase: Arc::new(Mutex::new(CalibrationPhaseSnapshot::default())), pairing_codes: Arc::new(Mutex::new(HashMap::new())), validation_runtime: Arc::new(Mutex::new(skill_data::validation_store::ValidationRuntime::default())), + task_heartbeats: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Record a heartbeat for a background task. Call once per tick after + /// the work for that tick completes. `duration_ms` is how long this tick + /// took; pass 0 if not measured. + /// + /// Also broadcasts an `activity-state` WebSocket event, throttled to at + /// most one event per task every `MIN_BROADCAST_INTERVAL_MS` (default 5s) + /// — so a 1s loop emits ~once every 5s and a future 100ms loop wouldn't + /// flood the bus. The 1st tick always broadcasts so the UI gets immediate + /// feedback when a task wakes up. + pub fn record_task_heartbeat(&self, task_id: &'static str, duration_ms: u64) { + const MIN_BROADCAST_INTERVAL_MS: u64 = 5_000; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let mut should_broadcast = false; + let mut tick_count = 1u64; + if let Ok(mut map) = self.task_heartbeats.lock() { + let entry = map.entry(task_id).or_default(); + entry.last_tick_unix_ms = now_ms; + entry.last_duration_ms = duration_ms; + entry.tick_count = entry.tick_count.saturating_add(1); + tick_count = entry.tick_count; + + // Always emit on the very first tick, then time-throttle. + if entry.last_broadcast_unix_ms == 0 + || now_ms.saturating_sub(entry.last_broadcast_unix_ms) >= MIN_BROADCAST_INTERVAL_MS + { + entry.last_broadcast_unix_ms = now_ms; + should_broadcast = true; + } + } + + if should_broadcast { + self.broadcast( + "activity-state", + serde_json::json!({ + "task_id": task_id, + "last_tick_unix_ms": now_ms, + "last_duration_ms": duration_ms, + "tick_count": tick_count, + }), + ); } } } diff --git a/crates/skill-daemon-state/src/text_embedder.rs b/crates/skill-daemon-state/src/text_embedder.rs index bedbe051..013a24b5 100644 --- a/crates/skill-daemon-state/src/text_embedder.rs +++ b/crates/skill-daemon-state/src/text_embedder.rs @@ -1,21 +1,60 @@ // SPDX-License-Identifier: GPL-3.0-only -//! Shared text embedder (fastembed ONNX models). +//! Shared text embedder (fastembed by default, optional RLX backend). //! //! A single `TextEmbedding` instance is created at daemon startup and shared //! across labels, hooks, screenshot OCR, and screenshot search. This avoids //! loading the ~130 MB ONNX model multiple times. +use anyhow::{anyhow, Result}; +use std::path::PathBuf; use std::sync::{Arc, Mutex, Once}; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextEmbeddingBackend { + FastEmbed, + Rlx, +} + +impl TextEmbeddingBackend { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "fastembed" | "fast-embed" | "ort" | "onnx" => Some(Self::FastEmbed), + "rlx" => Some(Self::Rlx), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::FastEmbed => "fastembed", + Self::Rlx => "rlx", + } + } +} + +enum LoadedTextEmbedder { + #[cfg(feature = "text-embeddings-fastembed")] + FastEmbed(fastembed::TextEmbedding), + #[cfg(feature = "text-embeddings-rlx")] + Rlx(Box), + /// Compiled without any embedding backend — every embed call returns None. + /// Keeps the surface compilable so callers don't need to gate their use sites. + #[allow(dead_code)] + None, +} + /// Shared, cheaply-cloneable handle to the text embedder. /// /// The ONNX model is loaded **lazily** on first use (not at daemon /// startup) so the GPU isn't hammered during init. #[derive(Clone)] pub struct SharedTextEmbedder { - inner: Arc>>, + inner: Arc>>, init: Arc, model_code: Arc>, + backend: Arc>, + rlx_device: Arc>, + rlx_max_seq: Arc>, } impl Default for SharedTextEmbedder { @@ -31,6 +70,9 @@ impl SharedTextEmbedder { inner: Arc::new(Mutex::new(None)), init: Arc::new(Once::new()), model_code: Arc::new(Mutex::new("nomic-ai/nomic-embed-text-v1.5".into())), + backend: Arc::new(Mutex::new(TextEmbeddingBackend::FastEmbed)), + rlx_device: Arc::new(Mutex::new(default_rlx_device())), + rlx_max_seq: Arc::new(Mutex::new(512)), } } @@ -54,32 +96,58 @@ impl SharedTextEmbedder { self.model_code.lock().map(|g| g.clone()).unwrap_or_default() } + pub fn set_backend(&self, backend: TextEmbeddingBackend) { + if let Ok(mut guard) = self.backend.lock() { + *guard = backend; + } + } + + pub fn backend(&self) -> TextEmbeddingBackend { + self.backend + .lock() + .map(|g| *g) + .unwrap_or(TextEmbeddingBackend::FastEmbed) + } + + pub fn set_rlx_device(&self, device: &str) { + if let Ok(mut guard) = self.rlx_device.lock() { + *guard = device.to_string(); + } + } + + pub fn rlx_device(&self) -> String { + self.rlx_device + .lock() + .map(|g| g.clone()) + .unwrap_or_else(|_| default_rlx_device()) + } + + pub fn set_rlx_max_seq(&self, max_seq: usize) { + if let Ok(mut guard) = self.rlx_max_seq.lock() { + *guard = max_seq.max(1); + } + } + + pub fn rlx_max_seq(&self) -> usize { + self.rlx_max_seq.lock().map(|g| *g).unwrap_or(512) + } + /// Reload the model (e.g. after changing model_code). /// Blocks while loading weights. Returns false for unknown model codes. pub fn reload(&self) -> bool { let code = self.model_code(); - let Some(fe_model) = model_code_to_fastembed(&code) else { - eprintln!("[text-embedder] unknown model code: {code}"); - return false; - }; - let cache_dir = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".cache") - .join("fastembed"); - let model = fastembed::TextEmbedding::try_new( - fastembed::InitOptions::new(fe_model) - .with_cache_dir(cache_dir) - .with_show_download_progress(true), - ) - .ok(); - let ok = model.is_some(); - if ok { - eprintln!("[text-embedder] {code} loaded"); - } else { - eprintln!("[text-embedder] failed to load {code}"); + let loaded = load_embedder(&code, self.backend(), &self.rlx_device(), self.rlx_max_seq(), true); + let ok = loaded.is_ok(); + match &loaded { + Ok(_) => eprintln!("[text-embedder] {} loaded via {}", code, self.backend().as_str()), + Err(e) => eprintln!( + "[text-embedder] failed to load {} via {}: {e:#}", + code, + self.backend().as_str() + ), } if let Ok(mut guard) = self.inner.lock() { - *guard = model; + *guard = loaded.ok(); } ok } @@ -88,26 +156,24 @@ impl SharedTextEmbedder { fn ensure_loaded(&self) { let inner = self.inner.clone(); let model_code = self.model_code.clone(); + let backend = self.backend.clone(); + let rlx_device = self.rlx_device.clone(); + let rlx_max_seq = self.rlx_max_seq.clone(); self.init.call_once(move || { let code = model_code.lock().map(|g| g.clone()).unwrap_or_default(); - let fe_model = model_code_to_fastembed(&code).unwrap_or(fastembed::EmbeddingModel::NomicEmbedTextV15); - let cache_dir = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".cache") - .join("fastembed"); - let model = fastembed::TextEmbedding::try_new( - fastembed::InitOptions::new(fe_model) - .with_cache_dir(cache_dir) - .with_show_download_progress(false), - ) - .ok(); - if model.is_some() { - eprintln!("[text-embedder] {code} loaded"); - } else { - eprintln!("[text-embedder] failed to load {code}"); + let backend = backend.lock().map(|g| *g).unwrap_or(TextEmbeddingBackend::FastEmbed); + let rlx_device = rlx_device + .lock() + .map(|g| g.clone()) + .unwrap_or_else(|_| default_rlx_device()); + let rlx_max_seq = rlx_max_seq.lock().map(|g| *g).unwrap_or(512); + let loaded = load_embedder(&code, backend, &rlx_device, rlx_max_seq, false); + match &loaded { + Ok(_) => eprintln!("[text-embedder] {code} loaded via {}", backend.as_str()), + Err(e) => eprintln!("[text-embedder] failed to load {code} via {}: {e:#}", backend.as_str()), } if let Ok(mut guard) = inner.lock() { - *guard = model; + *guard = loaded.ok(); } }); } @@ -118,7 +184,7 @@ impl SharedTextEmbedder { self.ensure_loaded(); let mut guard = self.inner.lock().ok()?; let model = guard.as_mut()?; - let mut vecs = model.embed(vec![text], None).ok()?; + let mut vecs = embed_with_loaded(model, vec![text]).ok()?; if vecs.is_empty() { None } else { @@ -131,12 +197,327 @@ impl SharedTextEmbedder { self.ensure_loaded(); let mut guard = self.inner.lock().ok()?; let model = guard.as_mut()?; - model.embed(texts, None).ok() + embed_with_loaded(model, texts).ok() + } +} + +fn default_rlx_device() -> String { + if cfg!(target_os = "macos") { + "metal".into() + } else { + "cpu".into() + } +} + +fn load_embedder( + code: &str, + backend: TextEmbeddingBackend, + rlx_device: &str, + rlx_max_seq: usize, + show_progress: bool, +) -> Result { + match backend { + TextEmbeddingBackend::FastEmbed => load_fastembed(code, show_progress), + TextEmbeddingBackend::Rlx => load_rlx_embedder(code, rlx_device, rlx_max_seq), + } +} + +#[cfg(feature = "text-embeddings-fastembed")] +fn load_fastembed(code: &str, show_progress: bool) -> Result { + let Some(fe_model) = model_code_to_fastembed(code) else { + return Err(anyhow!("unknown fastembed model code: {code}")); + }; + let cache_dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".cache") + .join("fastembed"); + let model = fastembed::TextEmbedding::try_new( + fastembed::InitOptions::new(fe_model) + .with_cache_dir(cache_dir) + .with_show_download_progress(show_progress), + )?; + Ok(LoadedTextEmbedder::FastEmbed(model)) +} + +#[cfg(not(feature = "text-embeddings-fastembed"))] +fn load_fastembed(_code: &str, _show_progress: bool) -> Result { + Err(anyhow!( + "FastEmbed backend requested but this build lacks the text-embeddings-fastembed feature" + )) +} + +fn embed_with_loaded(model: &mut LoadedTextEmbedder, texts: Vec<&str>) -> Result>> { + match model { + #[cfg(feature = "text-embeddings-fastembed")] + LoadedTextEmbedder::FastEmbed(model) => Ok(model.embed(texts, None)?), + #[cfg(feature = "text-embeddings-rlx")] + LoadedTextEmbedder::Rlx(model) => model.embed(texts), + LoadedTextEmbedder::None => Err(anyhow!( + "no text-embedding backend compiled in (enable text-embeddings-fastembed or text-embeddings-rlx)" + )), + } +} + +#[cfg(not(feature = "text-embeddings-rlx"))] +fn load_rlx_embedder(_code: &str, _device: &str, _max_seq: usize) -> Result { + Err(anyhow!( + "RLX text embeddings requested but this build lacks the text-embeddings-rlx feature" + )) +} + +#[cfg(feature = "text-embeddings-rlx")] +fn load_rlx_embedder(code: &str, device: &str, max_seq: usize) -> Result { + Ok(LoadedTextEmbedder::Rlx(Box::new(RlxTextEmbedding::from_repo( + code, device, max_seq, + )?))) +} + +#[cfg(feature = "text-embeddings-rlx")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RlxArch { + Bert, + NomicBert, +} + +#[cfg(feature = "text-embeddings-rlx")] +struct RlxTextEmbedding { + tokenizer: tokenizers::Tokenizer, + compiled: rlx::runtime::CompiledGraph, + arch: RlxArch, + hidden_size: usize, + pooling: rlx_models::Pooling, + compiled_bs: (usize, usize), + config_path: PathBuf, + weights_path: String, + device: rlx::Device, + max_seq: usize, +} + +#[cfg(feature = "text-embeddings-rlx")] +impl RlxTextEmbedding { + fn from_repo(repo_id: &str, device: &str, max_seq: usize) -> Result { + let repo = hf_hub::api::sync::ApiBuilder::new() + .with_progress(true) + .build()? + .model(repo_id.to_string()); + let config_path = repo.get("config.json")?; + let tokenizer_path = repo.get("tokenizer.json")?; + let weights_path = repo.get("model.safetensors")?; + let tokenizer = + tokenizers::Tokenizer::from_file(&tokenizer_path).map_err(|e| anyhow!("loading tokenizer.json: {e}"))?; + let arch = detect_rlx_arch(&config_path)?; + let pooling = default_pooling(repo_id); + let device = parse_rlx_device(device)?; + if !rlx::runtime::is_available(device) { + return Err(anyhow!("RLX device '{}' is not available in this build", device.name())); + } + let weights_path = weights_path + .to_str() + .ok_or_else(|| anyhow!("non-utf8 weights path"))? + .to_string(); + let (hidden_size, compiled) = compile_rlx_embedder(arch, &config_path, &weights_path, 1, 1, device)?; + + Ok(Self { + tokenizer, + compiled, + arch, + hidden_size, + pooling, + compiled_bs: (1, 1), + config_path, + weights_path, + device, + max_seq: max_seq.max(1), + }) + } + + fn embed(&mut self, texts: Vec<&str>) -> Result>> { + if texts.is_empty() { + return Ok(Vec::new()); + } + + let mut ids_rows = Vec::with_capacity(texts.len()); + for text in texts { + let enc = self + .tokenizer + .encode(text, true) + .map_err(|e| anyhow!("tokenizing text: {e}"))?; + let mut ids = enc.get_ids().iter().map(|&id| id as f32).collect::>(); + ids.truncate(self.max_seq); + if ids.is_empty() { + ids.push(0.0); + } + ids_rows.push(ids); + } + + let batch = ids_rows.len(); + let seq = ids_rows.iter().map(Vec::len).max().unwrap_or(1).min(self.max_seq); + self.ensure_compiled(batch, seq)?; + + let mut input_ids = vec![0.0f32; batch * seq]; + let mut attention_mask = vec![0.0f32; batch * seq]; + let token_type_ids = vec![0.0f32; batch * seq]; + let mut position_ids = vec![0.0f32; batch * seq]; + let mut lengths = Vec::with_capacity(batch); + + for (row_idx, ids) in ids_rows.iter().enumerate() { + let n = ids.len().min(seq); + lengths.push(n); + let base = row_idx * seq; + input_ids[base..base + n].copy_from_slice(&ids[..n]); + for i in 0..seq { + position_ids[base + i] = i as f32; + } + for i in 0..n { + attention_mask[base + i] = 1.0; + } + } + + let mut owned_inputs: Vec<(&str, &[f32])> = vec![ + ("input_ids", input_ids.as_slice()), + ("attention_mask", attention_mask.as_slice()), + ("token_type_ids", token_type_ids.as_slice()), + ]; + if matches!(self.arch, RlxArch::Bert) { + owned_inputs.push(("position_ids", position_ids.as_slice())); + } + + let outputs = self.compiled.run(&owned_inputs); + let hidden = outputs + .into_iter() + .next() + .ok_or_else(|| anyhow!("RLX embedder returned no output"))?; + let mut result = Vec::with_capacity(batch); + #[allow(clippy::needless_range_loop)] + for row in 0..batch { + let mut pooled = match self.pooling { + rlx_models::Pooling::Cls => { + let start = row * seq * self.hidden_size; + hidden[start..start + self.hidden_size].to_vec() + } + rlx_models::Pooling::Mean => { + let n = lengths[row].max(1); + let mut v = vec![0.0f32; self.hidden_size]; + for pos in 0..n { + let start = (row * seq + pos) * self.hidden_size; + for d in 0..self.hidden_size { + v[d] += hidden[start + d]; + } + } + for x in &mut v { + *x /= n as f32; + } + v + } + }; + l2_normalize(&mut pooled); + result.push(pooled); + } + Ok(result) + } + + fn ensure_compiled(&mut self, batch: usize, seq: usize) -> Result<()> { + if self.compiled_bs == (batch, seq) { + return Ok(()); + } + let (hidden_size, compiled) = compile_rlx_embedder( + self.arch, + &self.config_path, + &self.weights_path, + batch, + seq, + self.device, + )?; + self.hidden_size = hidden_size; + self.compiled = compiled; + self.compiled_bs = (batch, seq); + Ok(()) + } +} + +#[cfg(feature = "text-embeddings-rlx")] +fn detect_rlx_arch(config_path: &std::path::Path) -> Result { + let data = std::fs::read_to_string(config_path)?; + let json: serde_json::Value = serde_json::from_str(&data)?; + if json.get("img_size").is_some() && json.get("patch_size").is_some() { + return Err(anyhow!("RLX text embeddings do not support vision embedding configs")); + } + if json.get("rotary_emb_base").is_some() || json.get("rotary_emb_fraction").is_some() { + Ok(RlxArch::NomicBert) + } else { + Ok(RlxArch::Bert) + } +} + +#[cfg(feature = "text-embeddings-rlx")] +fn compile_rlx_embedder( + arch: RlxArch, + config_path: &std::path::Path, + weights_path: &str, + batch: usize, + seq: usize, + device: rlx::Device, +) -> Result<(usize, rlx::runtime::CompiledGraph)> { + let mut wm = rlx_models::WeightMap::from_file(weights_path)?; + let (graph, params, hidden_size) = match arch { + RlxArch::Bert => { + let cfg = rlx_models::BertConfig::from_file(config_path)?; + let hidden_size = cfg.hidden_size; + let (graph, params) = rlx_models::build_bert_graph_sized(&cfg, &mut wm, batch, seq)?; + (graph, params, hidden_size) + } + RlxArch::NomicBert => { + let cfg = rlx_models::NomicBertConfig::from_file(config_path)?; + let hidden_size = cfg.hidden_size; + let (graph, params) = rlx_models::nomic::build_nomic_graph_sized(&cfg, &mut wm, batch, seq)?; + (graph, params, hidden_size) + } + }; + let session = rlx::runtime::Session::new_with_precision(device, rlx::runtime::Precision::F16); + let mut compiled = session.compile(graph); + for (name, data) in ¶ms { + compiled.set_param(name, data); + } + Ok((hidden_size, compiled)) +} + +#[cfg(feature = "text-embeddings-rlx")] +fn default_pooling(repo_id: &str) -> rlx_models::Pooling { + let lower = repo_id.to_ascii_lowercase(); + if lower.contains("bge") || lower.contains("nomic") { + rlx_models::Pooling::Cls + } else { + rlx_models::Pooling::Mean + } +} + +#[cfg(feature = "text-embeddings-rlx")] +fn parse_rlx_device(tag: &str) -> Result { + match tag.to_ascii_lowercase().as_str() { + "cpu" => Ok(rlx::Device::Cpu), + "metal" => Ok(rlx::Device::Metal), + "mlx" => Ok(rlx::Device::Mlx), + "gpu" | "wgpu" => Ok(rlx::Device::Gpu), + "cuda" => Ok(rlx::Device::Cuda), + "rocm" => Ok(rlx::Device::Rocm), + "tpu" => Ok(rlx::Device::Tpu), + other => Err(anyhow!("unsupported RLX device '{other}'")), + } +} + +#[cfg(feature = "text-embeddings-rlx")] +fn l2_normalize(v: &mut [f32]) { + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + for x in v { + *x /= norm; + } } } /// Map a model code string to the fastembed enum variant. /// Returns `None` for unrecognized model codes. +#[cfg(feature = "text-embeddings-fastembed")] pub fn model_code_to_fastembed(code: &str) -> Option { Some(match code { "nomic-ai/nomic-embed-text-v1" => fastembed::EmbeddingModel::NomicEmbedTextV1, diff --git a/crates/skill-daemon-state/tests/cosine_parity.rs b/crates/skill-daemon-state/tests/cosine_parity.rs new file mode 100644 index 00000000..96e12920 --- /dev/null +++ b/crates/skill-daemon-state/tests/cosine_parity.rs @@ -0,0 +1,70 @@ +//! Cosine-distance parity: rlx text embedder vs fastembed on identical +//! input strings. Both backends must be enabled (`text-embeddings-fastembed` +//! + `text-embeddings-rlx`) for the test to run — otherwise the file +//! compiles to a no-op so the workspace `cargo test` doesn't break. +//! +//! Why cosine and not bit-exact: fastembed uses ONNX (ort/rten) with +//! INT8 weights for many models, while rlx-embed runs F32 inference +//! over the same model architecture. Outputs are numerically close +//! but not bit-identical; cosine similarity ≥ 0.99 on every test +//! string is the operational parity bar. +//! +//! Run: +//! ```sh +//! cargo test -p skill-daemon-state --release --test cosine_parity \ +//! --features text-embeddings-fastembed,text-embeddings-rlx-metal -- --nocapture +//! ``` + +#![cfg(all(feature = "text-embeddings-fastembed", feature = "text-embeddings-rlx"))] + +use skill_daemon_state::text_embedder::{SharedTextEmbedder, TextEmbeddingBackend}; + +const CASES: &[&str] = &[ + "hello world", + "the quick brown fox jumps over the lazy dog", + "transformer attention scales as O(n^2) in sequence length", + "Apple Silicon, with its unified memory architecture, makes Metal compute kernels especially efficient.", + "Multilingual embedding: 嗨 こんにちは 안녕 مرحبا", +]; + +/// Cosine similarity between two equal-length vectors. +fn cosine(a: &[f32], b: &[f32]) -> f32 { + assert_eq!(a.len(), b.len(), "embedding dim mismatch: {} vs {}", a.len(), b.len()); + let mut dot = 0f32; + let mut na = 0f32; + let mut nb = 0f32; + for i in 0..a.len() { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + let n = (na.sqrt() * nb.sqrt()).max(1e-12); + dot / n +} + +#[test] +fn rlx_vs_fastembed_cosine_at_least_099() { + // Use BGE-small-en-v1.5 — supported by both backends. + let model = "BAAI/bge-small-en-v1.5"; + + let fe = SharedTextEmbedder::new(); + fe.set_model_code(model); + fe.set_backend(TextEmbeddingBackend::FastEmbed); + assert!(fe.reload(), "FastEmbed reload failed (network or model issue?)"); + + let rl = SharedTextEmbedder::new(); + rl.set_model_code(model); + rl.set_backend(TextEmbeddingBackend::Rlx); + assert!(rl.reload(), "RLX reload failed (network or model issue?)"); + + let mut min_cos = 1.0f32; + for s in CASES { + let a = fe.embed(s).expect("fastembed produced no embedding"); + let b = rl.embed(s).expect("rlx produced no embedding"); + let c = cosine(&a, &b); + eprintln!("cosine({s:?}) = {c:.6}"); + min_cos = min_cos.min(c); + assert!(c >= 0.99, "cosine {c:.4} below 0.99 threshold for input {s:?}"); + } + eprintln!("min cosine across cases: {min_cos:.6}"); +} diff --git a/crates/skill-daemon/.cargo/config.toml b/crates/skill-daemon/.cargo/config.toml index 3747670f..8f878b74 100644 --- a/crates/skill-daemon/.cargo/config.toml +++ b/crates/skill-daemon/.cargo/config.toml @@ -1,6 +1,5 @@ -# Force static linking for llama-cpp-4 and its dependencies -# This ensures the skill-daemon binary is self-contained and doesn't -# depend on external .dylib files, which fixes the @rpath issues on macOS +# macOS linker flags: search_paths_first ensures system frameworks are found +# before any local paths; dead_strip removes unused symbols to reduce binary size. [target.'cfg(target_os = "macos")'] rustflags = [ @@ -8,17 +7,6 @@ rustflags = [ "-C", "link-arg=-Wl,-dead_strip", ] -# For llama-cpp-4 specifically, we want static linking -# This is controlled by the llama-cpp-4 build script, but we can -# encourage static linking through these flags -[profile.release] -panic = "abort" # Reduce binary size -lto = true # Enable link-time optimization -codegen-units = 1 - -# When building for macOS, prefer static linking for llama-cpp -# Note: We cannot use -static on macOS as it conflicts with system frameworks -# Instead, we rely on the llama-cpp-4 'native' feature for static linking [target.aarch64-apple-darwin] rustflags = [ "-C", "link-arg=-Wl,-search_paths_first", diff --git a/crates/skill-daemon/Cargo.toml b/crates/skill-daemon/Cargo.toml index 0ff3e4b9..dfe48ec4 100644 --- a/crates/skill-daemon/Cargo.toml +++ b/crates/skill-daemon/Cargo.toml @@ -5,33 +5,67 @@ edition = "2021" license = "GPL-3.0-only" [features] -default = ["llm", "embed-exg", "mw75-rfcomm"] +default = ["llm", "embed-exg", "mw75-rfcomm", "text-embeddings-rlx"] +# `llm` gates the daemon-side glue (routes, init call, settings handlers). +# Every backend flavor below enables it so the engine is actually wired up; +# without this, skill-llm::init would never be called and the linker would +# drop the entire engine module as unreachable. llm = ["skill-llm/llm"] mw75-rfcomm = ["mw75/rfcomm", "skill-devices/mw75-rfcomm"] -llm-metal = ["llm", "skill-llm/llm-metal"] -llm-cuda = ["llm", "skill-llm/llm-cuda"] -llm-vulkan = ["llm", "skill-llm/llm-vulkan"] -llm-mtmd = ["llm", "skill-llm/llm-mtmd"] +llm-rlx = ["llm", "skill-llm/llm-rlx"] +llm-rlx-cpu = ["llm", "skill-llm/llm-rlx-cpu"] +llm-rlx-metal = ["llm", "skill-llm/llm-rlx-metal"] +llm-rlx-mlx = ["llm", "skill-llm/llm-rlx-mlx"] +llm-rlx-cuda = ["llm", "skill-llm/llm-rlx-cuda"] +llm-rlx-rocm = ["llm", "skill-llm/llm-rlx-rocm"] +llm-rlx-wgpu = ["llm", "skill-llm/llm-rlx-wgpu"] +text-embeddings-fastembed = ["dep:fastembed", "skill-daemon-state/text-embeddings-fastembed", "skill-screenshots/text-embeddings-fastembed"] +text-embeddings-rlx = ["skill-daemon-state/text-embeddings-rlx", "skill-screenshots/text-embeddings-rlx"] +text-embeddings-rlx-metal = ["text-embeddings-rlx", "skill-daemon-state/text-embeddings-rlx-metal", "skill-screenshots/text-embeddings-rlx-metal"] +text-embeddings-rlx-cuda = ["text-embeddings-rlx", "skill-daemon-state/text-embeddings-rlx-cuda", "skill-screenshots/text-embeddings-rlx-cuda"] +text-embeddings-rlx-rocm = ["text-embeddings-rlx", "skill-daemon-state/text-embeddings-rlx-rocm", "skill-screenshots/text-embeddings-rlx-rocm"] +text-embeddings-rlx-wgpu = ["text-embeddings-rlx", "skill-daemon-state/text-embeddings-rlx-wgpu", "skill-screenshots/text-embeddings-rlx-wgpu"] # EEG embedding encoders -# embed-exg enables all encoder backends; individual flags available below. -embed-exg = ["dep:skill-exg", "skill-exg/cubecl", "skill-router/gpu", "skill-eeg/gpu", "embed-zuna", "embed-zuna-gpu", "embed-zuna-gpu-f16", "embed-luna", "embed-reve", "embed-osf", "embed-sleepfm", "embed-sleeplm", "embed-steegformer", "embed-tribev2", "embed-neurorvq"] -embed-exg-mlx = ["embed-exg", "skill-router/mlx", "skill-eeg/mlx", "embed-zuna-mlx"] -cpu-only = ["dep:skill-exg", "skill-router/cpu"] -embed-zuna = ["dep:zuna-rs", "dep:burn", "dep:burn-ndarray", "dep:ndarray"] -# GPU-accelerated ZUNA encoder (Metal on macOS, Vulkan on Linux) -embed-zuna-gpu = ["embed-zuna", "zuna-rs/wgpu", "burn/wgpu"] -# GPU f16 half-precision — faster than f32 on Apple Silicon and modern GPUs -embed-zuna-gpu-f16 = ["embed-zuna", "zuna-rs/wgpu-f16", "burn/wgpu", "dep:half"] -# MLX-accelerated ZUNA encoder (Apple Silicon native) -embed-zuna-mlx = ["embed-zuna", "zuna-rs/mlx", "dep:burn-mlx"] -embed-luna = ["dep:luna-rs", "dep:burn", "dep:burn-ndarray", "dep:ndarray"] -embed-reve = ["dep:reve-rs", "dep:burn", "dep:burn-ndarray", "dep:ndarray"] -embed-osf = ["dep:osf-rs", "dep:burn", "dep:burn-ndarray", "dep:ndarray"] -embed-sleepfm = ["dep:sleepfm", "dep:burn", "dep:burn-ndarray"] -embed-sleeplm = ["dep:sleeplm", "dep:burn", "dep:burn-ndarray"] -embed-steegformer = ["dep:steegformer", "dep:burn", "dep:burn-ndarray"] -embed-tribev2 = ["dep:tribev2", "dep:burn", "dep:burn-ndarray"] -embed-neurorvq = ["skill-exg/neurorvq-ndarray"] +# embed-exg enables all encoder backends (ZUNA, LUNA, TRIBEv2, NeuroRVQ). +# All use the RLX runtime. Device is chosen at runtime by resolve_exg_device(): +# macOS default: Metal → CPU Linux/Windows default: CUDA → wgpu → CPU +# Compile-in backend tiers via embed-exg-{metal,mlx,gpu,cuda,rocm} below. +# reve/osf/sleepfm/sleeplm/steegformer pending RLX migration — see comments below. +embed-exg = ["dep:skill-exg", "embed-zuna", "embed-luna", "embed-tribev2", "embed-neurorvq", "embed-eegdino", + "skill-eeg/rlx-fft"] +# Backend tiers — each enables the matching RLX backend in every active encoder AND the FFT path. +# macOS: Metal is also auto-enabled via [target.'cfg(macos)'.dependencies] so +# the daemon always picks up Metal on Apple Silicon without extra flags. +embed-exg-metal = ["embed-exg", "skill-router/gpu-apple", "rlx/metal", + "zuna-rs/rlx-metal", "luna-rs/rlx-metal", "tribev2/rlx-metal", + "skill-exg/neurorvq-metal", "skill-exg/eegdino-metal", "skill-eeg/rlx-fft-metal"] +embed-exg-mlx = ["embed-exg", "skill-router/gpu-apple", "rlx/mlx", + "zuna-rs/rlx-mlx", "luna-rs/rlx-mlx", "tribev2/rlx-mlx", + "skill-exg/neurorvq-mlx", "skill-exg/eegdino-mlx"] +embed-exg-gpu = ["embed-exg", "skill-router/gpu", "rlx/gpu", + "zuna-rs/rlx-gpu", "luna-rs/rlx-gpu", "tribev2/rlx-gpu", + "skill-exg/neurorvq-gpu", "skill-exg/eegdino-gpu", "skill-eeg/rlx-fft-gpu"] +embed-exg-cuda = ["embed-exg", "rlx/cuda", + "zuna-rs/rlx-cuda", "luna-rs/rlx-cuda", "tribev2/rlx-cuda", + "skill-exg/neurorvq-cuda", "skill-exg/eegdino-cuda", "skill-eeg/rlx-fft-cuda"] +embed-exg-rocm = ["embed-exg", "rlx/rocm", + "zuna-rs/rlx-rocm", "luna-rs/rlx-rocm", "tribev2/rlx-rocm", + "skill-exg/neurorvq-rocm", "skill-exg/eegdino-rocm", "skill-eeg/rlx-fft-rocm"] +cpu-only = ["dep:skill-exg", "skill-router/cpu"] +embed-zuna = ["dep:zuna-rs", "dep:rlx", "dep:ndarray"] +embed-luna = ["dep:luna-rs", "dep:rlx"] +embed-tribev2 = ["dep:tribev2", "dep:rlx"] +embed-neurorvq = ["skill-exg/neurorvq-ndarray"] +embed-eegdino = ["skill-exg/eegdino-rlx"] +# reve/osf/sleepfm/sleeplm/steegformer: pending RLX migration. +# When new rlx-cpu versions are published, restore as: +# embed-reve = ["dep:reve-rs"] +# embed-exg-metal += "reve-rs/rlx-metal", etc. +# embed-reve = [...] +# embed-osf = [...] +# embed-sleepfm = [...] +# embed-sleeplm = [...] +# embed-steegformer = [...] [[bin]] name = "skill-daemon" @@ -54,7 +88,8 @@ regex = "1" notify = { version = "8", default-features = false, features = ["macos_fsevent"] } rusqlite = { workspace = true } fast-hnsw = { version = "1.0.1" } -fastembed = { version = "5.13.0", features = ["ort-download-binaries-native-tls"] } +ndarray = { version = "0.17", optional = true } +fastembed = { version = "5.13.0", features = ["ort-download-binaries-native-tls"], optional = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["full"] } @@ -79,7 +114,7 @@ skill-data = { path = "../skill-data", features = ["parquet"] } skill-settings = { path = "../skill-settings" } skill-constants = { path = "../skill-constants" } skill-history = { path = "../skill-history", features = ["parquet"] } -skill-label-index = { path = "../skill-label-index" } +skill-label-index = { path = "../skill-label-index", features = ["turboquant-index"] } skill-jobs = { path = "../skill-jobs" } skill-commands = { path = "../skill-commands" } skill-health = { path = "../skill-health" } @@ -99,19 +134,10 @@ neurosity = { version = "0.0.1", default-features = false } brainvision = { version = "0.0.1", default-features = false } brainmaster = { version = "0.0.1", default-features = false } # EXG encoder crates (behind feature flags) -zuna-rs = { version = "0.1.4", default-features = false, features = ["ndarray"], optional = true } -burn-mlx = { git = "https://github.com/eidola-ai/burn-mlx", branch = "burn-0-20", optional = true } -luna-rs = { version = "0.0.3", default-features = false, features = ["ndarray"], optional = true } -reve-rs = { version = "0.0.1", default-features = false, features = ["ndarray"], optional = true } -osf-rs = { version = "0.0.1", default-features = false, features = ["ndarray"], optional = true } -sleepfm = { version = "0.0.1", default-features = false, features = ["ndarray"], optional = true } -sleeplm = { version = "0.0.1", default-features = false, features = ["ndarray"], optional = true } -steegformer = { version = "0.1.0", default-features = false, features = ["ndarray"], optional = true } -tribev2 = { version = "0.0.4", default-features = false, features = ["ndarray"], optional = true } -burn = { version = "0.20.1", default-features = false, features = ["std"], optional = true } -half = { version = "2", optional = true } -burn-ndarray = { version = "0.20.1", default-features = false, features = ["std", "simd", "multi-threads"], optional = true } -ndarray = { version = "0.17", optional = true } +rlx = { version = "0.2.5", default-features = false, optional = true } +zuna-rs = { version = "0.2.0", default-features = false, features = ["rlx-cpu"], optional = true } +luna-rs = { version = "0.1.0", default-features = false, features = ["rlx-cpu"], optional = true } +tribev2 = { version = "0.1.0", default-features = false, features = ["rlx-cpu"], optional = true } skill-location = { path = "../skill-location" } skill-llm = { path = "../skill-llm" } skill-tts = { path = "../skill-tts" } @@ -130,13 +156,16 @@ mw75 = { version = "0.0.6" } rlsl = { version = "0.0.4" } tokio-tungstenite = "0.28" -# macOS: use Apple Accelerate for BLAS (AMX coprocessor on M-series) +# macOS: enable Metal backend for ZUNA/LUNA/TRIBEv2 GPU paths (embed-*-gpu). [target.'cfg(target_os = "macos")'.dependencies] -burn-ndarray = { version = "0.20.1", default-features = false, features = ["blas-accelerate"], optional = true } - -# Linux: use system OpenBLAS when available -[target.'cfg(target_os = "linux")'.dependencies] -burn-ndarray = { version = "0.20.1", default-features = false, features = ["blas-openblas-system"], optional = true } +zuna-rs = { version = "0.2.0", default-features = false, features = ["rlx-metal"], optional = true } +luna-rs = { version = "0.1.0", default-features = false, features = ["rlx-metal"], optional = true } +tribev2 = { version = "0.1.0", default-features = false, features = ["rlx-metal"], optional = true } +rlx = { version = "0.2.5", default-features = false, features = ["metal"], optional = true } +skill-exg = { path = "../skill-exg", features = ["neurorvq-metal", "eegdino-metal"], optional = true } +skill-eeg = { path = "../skill-eeg", features = ["rlx-fft-metal"] } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication", "NSPasteboard"] } [lints] workspace = true diff --git a/crates/skill-daemon/build.rs b/crates/skill-daemon/build.rs index cfd3d6d5..b58cffb4 100644 --- a/crates/skill-daemon/build.rs +++ b/crates/skill-daemon/build.rs @@ -4,22 +4,15 @@ //! Fixes missing linker directives when using prebuilt llama-cpp-sys static //! archives on Linux. The upstream build.rs prebuilt code path omits: //! - `cargo:rustc-link-lib=vulkan` (Vulkan symbols from ggml-vulkan.cpp) -//! - `cargo:rustc-link-search` for openblas in its alternatives directory +//! - OpenBLAS search path + rpath (see `build-support/linux_openblas.rs`) + +mod linux_openblas { + include!("../../build-support/linux_openblas.rs"); +} fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { println!("cargo:rustc-link-lib=vulkan"); - - // openblas installs to a subdirectory managed by update-alternatives; - // the linker won't find it with just -L /usr/lib/x86_64-linux-gnu. - for dir in &[ - "/usr/lib/x86_64-linux-gnu/openblas-pthread", - "/usr/lib/x86_64-linux-gnu/openblas-openmp", - "/usr/lib/x86_64-linux-gnu", - ] { - if std::path::Path::new(dir).exists() { - println!("cargo:rustc-link-search={dir}"); - } - } + linux_openblas::link_system_openblas(true); } } diff --git a/crates/skill-daemon/src/activity.rs b/crates/skill-daemon/src/activity.rs index 25718c17..b50cae78 100644 --- a/crates/skill-daemon/src/activity.rs +++ b/crates/skill-daemon/src/activity.rs @@ -118,8 +118,222 @@ fn run_osascript(script: &str) -> Option { Some(String::from_utf8_lossy(&out.stdout).to_string()) } +/// macOS: try the native Accessibility-API path first; fall back to +/// AppleScript only when Accessibility permission has not been granted yet. +/// +/// The native path (`ax_poll_active_window`) requires ONE one-time +/// "Accessibility" permission for NeuroSkill that covers every application +/// forever — no per-app Automation dialogs appear. The AppleScript fallback +/// (`applescript_poll_active_window`) may trigger macOS TCC dialogs for each +/// new app that comes to the foreground. #[cfg(target_os = "macos")] fn poll_active_window() -> Option { + ax_poll_active_window().or_else(applescript_poll_active_window) +} + +/// Native macOS window polling via NSWorkspace + Accessibility API. +/// +/// * App name / path — obtained from `NSWorkspace.frontmostApplication` +/// (no permissions required at all). +/// * Window title — obtained via `AXFocusedWindow` + `AXTitle` +/// (single one-time "Accessibility" permission for NeuroSkill). +/// * Document path — obtained via `AXDocument` on the focused window +/// (same Accessibility permission; replaces the per-app AppleScript lookup). +/// +/// Returns `None` (causing a fall-through to AppleScript) if Accessibility +/// permission is not yet granted. +#[cfg(target_os = "macos")] +fn ax_poll_active_window() -> Option { + use std::ffi::{c_void, CStr}; + use std::os::raw::c_char; + + type CFTypeRef = *const c_void; + type CFStringRef = *const c_void; + type CFAllocatorRef = *const c_void; + type AXUIElementRef = *const c_void; + type AXError = i32; + + const AX_SUCCESS: AXError = 0; + const KCF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; + + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> bool; + fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef; + fn AXUIElementCopyAttributeValue( + element: AXUIElementRef, + attribute: CFStringRef, + value: *mut CFTypeRef, + ) -> AXError; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFStringCreateWithCString(alloc: CFAllocatorRef, c_str: *const c_char, encoding: u32) -> CFStringRef; + fn CFStringGetLength(s: CFStringRef) -> isize; + fn CFStringGetMaximumSizeForEncoding(len: isize, encoding: u32) -> isize; + fn CFStringGetCString(s: CFStringRef, buf: *mut c_char, size: isize, encoding: u32) -> bool; + fn CFRelease(cf: CFTypeRef); + } + + // SAFETY: AXIsProcessTrusted is thread-safe and returns immediately. + if !unsafe { AXIsProcessTrusted() } { + // Accessibility not yet granted — fall through to AppleScript path. + return None; + } + + // ── Step 1: frontmost app info from NSWorkspace (zero permissions) ──────── + let (pid, app_name, app_path) = { + use objc2::msg_send; + use objc2::runtime::AnyObject; + use objc2_app_kit::NSWorkspace; + + // SAFETY: NSWorkspace and NSRunningApplication are stable AppKit APIs. + // Returned Objective-C objects have autorelease lifetime tied to the + // current thread's autorelease pool which Tauri/the OS maintains. + unsafe { + let workspace = NSWorkspace::sharedWorkspace(); + let front_app: Option<&AnyObject> = msg_send![&workspace, frontmostApplication]; + let front_app = front_app?; + + let pid: i32 = msg_send![front_app, processIdentifier]; + if pid <= 0 { + return None; + } + + let name_obj: Option<&AnyObject> = msg_send![front_app, localizedName]; + let app_name = name_obj + .map(|n| { + let bytes: *const c_char = msg_send![n, UTF8String]; + if bytes.is_null() { + String::new() + } else { + CStr::from_ptr(bytes).to_string_lossy().into_owned() + } + }) + .unwrap_or_default(); + + let url_obj: Option<&AnyObject> = msg_send![front_app, executableURL]; + let app_path = url_obj + .and_then(|u| { + let path_obj: Option<&AnyObject> = msg_send![u, path]; + path_obj.map(|p| { + let bytes: *const c_char = msg_send![p, UTF8String]; + if bytes.is_null() { + String::new() + } else { + CStr::from_ptr(bytes).to_string_lossy().into_owned() + } + }) + }) + .unwrap_or_default(); + + (pid, app_name, app_path) + } + }; + + if app_name.is_empty() { + return None; + } + + // ── Step 2: window title + document path via AXUIElement ───────────────── + // One "Accessibility" permission covers all apps — no per-app dialogs. + // SAFETY: All CF/AX objects are null-checked before use; owned refs are + // released via CFRelease before the block exits. + let (window_title, document_path) = unsafe { + /// Convert a non-null CFStringRef to a Rust `String`. + /// + /// SAFETY: `s` must be a valid, non-null CFStringRef. + unsafe fn cfstr_to_string(s: CFStringRef, enc: u32) -> String { + // SAFETY: upheld by the caller (see fn-level doc). + unsafe { + let len = CFStringGetLength(s); + let max = CFStringGetMaximumSizeForEncoding(len, enc) + 1; + let mut buf: Vec = vec![0; max as usize]; + if CFStringGetCString(s, buf.as_mut_ptr(), max, enc) { + CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned() + } else { + String::new() + } + } + } + + let key_focused_win = + CFStringCreateWithCString(std::ptr::null(), c"AXFocusedWindow".as_ptr(), KCF_STRING_ENCODING_UTF8); + let key_title = CFStringCreateWithCString(std::ptr::null(), c"AXTitle".as_ptr(), KCF_STRING_ENCODING_UTF8); + let key_document = + CFStringCreateWithCString(std::ptr::null(), c"AXDocument".as_ptr(), KCF_STRING_ENCODING_UTF8); + + let app_ax = AXUIElementCreateApplication(pid); + + let mut win_ref: CFTypeRef = std::ptr::null(); + let err = AXUIElementCopyAttributeValue(app_ax, key_focused_win, &mut win_ref); + + let (title, doc_path) = if err == AX_SUCCESS && !win_ref.is_null() { + let mut title_ref: CFTypeRef = std::ptr::null(); + let title = if AXUIElementCopyAttributeValue(win_ref, key_title, &mut title_ref) == AX_SUCCESS + && !title_ref.is_null() + { + let t = cfstr_to_string(title_ref, KCF_STRING_ENCODING_UTF8); + CFRelease(title_ref); + t + } else { + String::new() + }; + + let mut doc_ref: CFTypeRef = std::ptr::null(); + let doc_path = if AXUIElementCopyAttributeValue(win_ref, key_document, &mut doc_ref) == AX_SUCCESS + && !doc_ref.is_null() + { + // AXDocument returns a URL string: "file:///path/to/doc.txt" + let raw = cfstr_to_string(doc_ref, KCF_STRING_ENCODING_UTF8); + CFRelease(doc_ref); + let path = raw.strip_prefix("file://").unwrap_or(&raw); + let decoded = urlencoding::decode(path) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| path.to_string()); + if decoded.is_empty() { + None + } else { + Some(decoded) + } + } else { + None + }; + + CFRelease(win_ref); + (title, doc_path) + } else { + (String::new(), None) + }; + + // SAFETY: app_ax is a retained AXUIElementRef (CFType); must be released. + CFRelease(app_ax as CFTypeRef); + CFRelease(key_focused_win); + CFRelease(key_title); + CFRelease(key_document); + + (title, doc_path) + }; + + Some(ActiveWindowInfo { + app_name, + app_path, + window_title, + document_path, + activated_at: unix_secs(), + browser_title: None, // Enriched later in run_poller. + monitor_id: None, // Enriched later if multi-monitor detection succeeds. + }) +} + +/// AppleScript fallback for active-window polling (macOS). +/// +/// Used only when Accessibility permission has not been granted yet. +/// May trigger macOS TCC Automation permission dialogs for each new +/// foreground application. +#[cfg(target_os = "macos")] +fn applescript_poll_active_window() -> Option { let script = r#" tell application "System Events" set frontApp to first application process whose frontmost is true @@ -189,70 +403,225 @@ return appName & "|||" & appPath & "|||" & winTitle & "|||" & docPath"#; } /// Poll all visible windows on non-primary monitors (macOS only). -/// Returns a list of windows that are on secondary screens. +/// +/// Uses `CGWindowListCopyWindowInfo` (CoreGraphics) and `CGMainDisplayID` / +/// `CGDisplayPixelsWide` to detect secondary-monitor windows without any +/// AppleScript or TCC permission prompts. +/// +/// Window titles (`kCGWindowName`) may be empty without Screen Recording +/// permission; owner names (`kCGWindowOwnerName`) are always available. #[cfg(target_os = "macos")] fn poll_secondary_windows() -> Vec { - // Use AppleScript to get all visible windows with their positions, - // then compare against screen bounds to determine which monitor. - let script = r#" -set result to "" -tell application "System Events" - set frontName to name of first application process whose frontmost is true - repeat with proc in (application processes whose visible is true) - set procName to name of proc - if procName is not frontName then - try - repeat with w in windows of proc - try - set winTitle to name of w - set winPos to position of w - set xPos to item 1 of winPos - -- Use x position to infer monitor (primary is typically x >= 0 and < primary width) - set result to result & procName & "|||" & winTitle & "|||" & xPos & linefeed - end try - end repeat - end try - end if - end repeat -end tell -return result"#; - - let out = match run_osascript(script) { - Some(s) => s, - None => return vec![], - }; + use std::ffi::{c_void, CStr}; + use std::os::raw::c_char; + + type CFTypeRef = *const c_void; + type CFDictionaryRef = *const c_void; + type CFStringRef = *const c_void; + type CFAllocatorRef = *const c_void; + type CFArrayRef = *const c_void; + type CFIndex = isize; + type CFNumberType = i32; + type CGWindowID = u32; + + const ON_SCREEN_ONLY: u32 = 1 << 0; + const EXCLUDE_DESKTOP: u32 = 1 << 4; + const K_CG_NULL_WINDOW_ID: CGWindowID = 0; + const K_CF_NUMBER_SINT32_TYPE: CFNumberType = 3; + const K_CF_NUMBER_FLOAT64_TYPE: CFNumberType = 13; + const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGWindowListCopyWindowInfo(option: u32, relativeToWindow: CGWindowID) -> CFArrayRef; + fn CGMainDisplayID() -> u32; + fn CGDisplayPixelsWide(display: u32) -> usize; + } - // Parse: each line is "appName|||windowTitle|||xPosition" - // Query actual primary screen width to avoid hardcoded values. - let primary_width: i64 = run_osascript("tell application \"Finder\" to get bounds of window of desktop") - .and_then(|s| s.split(',').nth(2)?.trim().parse::().ok()) - .unwrap_or(2000); + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFArrayGetCount(theArray: CFArrayRef) -> CFIndex; + fn CFArrayGetValueAtIndex(theArray: CFArrayRef, idx: CFIndex) -> CFTypeRef; + fn CFDictionaryGetValue(dict: CFDictionaryRef, key: CFStringRef) -> CFTypeRef; + fn CFNumberGetValue(number: CFTypeRef, the_type: CFNumberType, value_ptr: *mut i64) -> bool; + fn CFRelease(cf: CFTypeRef); + fn CFStringCreateWithCString(alloc: CFAllocatorRef, c_str: *const c_char, encoding: u32) -> CFStringRef; + fn CFStringGetLength(s: CFStringRef) -> isize; + fn CFStringGetMaximumSizeForEncoding(len: isize, encoding: u32) -> isize; + fn CFStringGetCString(s: CFStringRef, buf: *mut c_char, size: isize, encoding: u32) -> bool; + } + + // SAFETY: CoreGraphics C APIs — all pointers are valid and non-null-checked. + unsafe { + /// Create a UTF-8 CFString from a NUL-terminated byte literal. + /// + /// SAFETY: `s` must be a NUL-terminated byte slice. Caller must CFRelease. + unsafe fn cfstr(s: &[u8]) -> CFStringRef { + // SAFETY: upheld by caller (NUL-terminated slice, result CFRelease'd). + unsafe { + CFStringCreateWithCString(std::ptr::null(), s.as_ptr() as *const c_char, K_CF_STRING_ENCODING_UTF8) + } + } - out.lines() - .filter_map(|line| { - let line = line.trim(); - if line.is_empty() { + /// Read a CFNumber as i32. + /// + /// SAFETY: `n` must be a valid CFNumber (or null). + unsafe fn cfnum_i32(n: CFTypeRef) -> Option { + if n.is_null() { return None; } - let mut parts = line.splitn(3, "|||"); - let app_name = parts.next()?.trim().to_string(); - let window_title = parts.next()?.trim().to_string(); - let x_pos: i64 = parts.next()?.trim().parse().ok()?; - if app_name.is_empty() || window_title.is_empty() { + let mut v: i64 = 0; + // SAFETY: `n` is non-null and a valid CFNumber; `v` is a local i64. + unsafe { + if CFNumberGetValue(n, K_CF_NUMBER_SINT32_TYPE, &mut v) { + Some(v as i32) + } else { + None + } + } + } + + /// Read a CFNumber as f64. + /// + /// SAFETY: `n` must be a valid CFNumber (or null). + unsafe fn cfnum_f64(n: CFTypeRef) -> Option { + if n.is_null() { return None; } - // If window is outside primary monitor bounds, it's on a secondary monitor. - if x_pos < 0 || x_pos >= primary_width { - Some(SecondaryWindowInfo { - app_name, - window_title, - monitor_id: if x_pos < 0 { 2 } else { 1 }, - }) - } else { - None + let mut v: i64 = 0; + // SAFETY: `n` is non-null and a valid CFNumber; reinterpret bits as f64. + unsafe { + // CFNumberGetValue writes the numeric bits; reinterpret as f64. + if CFNumberGetValue(n, K_CF_NUMBER_FLOAT64_TYPE, &mut v) { + Some(f64::from_bits(v as u64)) + } else { + None + } } - }) - .collect() + } + + /// Convert a non-null CFStringRef to a Rust String. + /// + /// SAFETY: `s` must be a valid, non-null CFStringRef. + unsafe fn cfstr_to_string(s: CFStringRef) -> String { + // SAFETY: upheld by the caller (see fn-level doc). + unsafe { + let len = CFStringGetLength(s); + let max = CFStringGetMaximumSizeForEncoding(len, K_CF_STRING_ENCODING_UTF8) + 1; + let mut buf: Vec = vec![0; max as usize]; + if CFStringGetCString(s, buf.as_mut_ptr(), max, K_CF_STRING_ENCODING_UTF8) { + CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned() + } else { + String::new() + } + } + } + + // Primary display width — used to determine which monitor a window is on. + let primary_width = CGDisplayPixelsWide(CGMainDisplayID()) as i64; + + let key_pid = cfstr(b"kCGWindowOwnerPID\0"); + let key_layer = cfstr(b"kCGWindowLayer\0"); + let key_owner_name = cfstr(b"kCGWindowOwnerName\0"); + let key_name = cfstr(b"kCGWindowName\0"); + let key_bounds = cfstr(b"kCGWindowBounds\0"); + let key_x = cfstr(b"X\0"); + + let list = CGWindowListCopyWindowInfo(ON_SCREEN_ONLY | EXCLUDE_DESKTOP, K_CG_NULL_WINDOW_ID); + if list.is_null() { + CFRelease(key_pid); + CFRelease(key_layer); + CFRelease(key_owner_name); + CFRelease(key_name); + CFRelease(key_bounds); + CFRelease(key_x); + return vec![]; + } + + // Identify the frontmost app's PID so we can skip its windows. + let frontmost_pid: i32 = { + use objc2::msg_send; + use objc2::runtime::AnyObject; + use objc2_app_kit::NSWorkspace; + let workspace = NSWorkspace::sharedWorkspace(); + let front_app: Option<&AnyObject> = msg_send![&workspace, frontmostApplication]; + front_app.map(|a| msg_send![a, processIdentifier]).unwrap_or(-1) + }; + + let count = CFArrayGetCount(list); + let mut results: Vec = Vec::new(); + + for i in 0..count { + let dict = CFArrayGetValueAtIndex(list, i); + if dict.is_null() { + continue; + } + + // Layer 0 = normal windows only. + let layer = cfnum_i32(CFDictionaryGetValue(dict, key_layer)).unwrap_or(-1); + if layer != 0 { + continue; + } + + // Skip the frontmost app's windows (those belong to primary tracking). + let pid = cfnum_i32(CFDictionaryGetValue(dict, key_pid)).unwrap_or(-1); + if pid == frontmost_pid { + continue; + } + + // Window x-position from bounds dictionary. + let bounds_dict = CFDictionaryGetValue(dict, key_bounds); + if bounds_dict.is_null() { + continue; + } + let x_val = CFDictionaryGetValue(bounds_dict, key_x); + let x_pos = cfnum_f64(x_val).unwrap_or(0.0) as i64; + + // Only include windows that are outside the primary monitor. + if x_pos >= 0 && x_pos < primary_width { + continue; + } + + let owner_name_ref = CFDictionaryGetValue(dict, key_owner_name); + if owner_name_ref.is_null() { + continue; + } + let app_name = cfstr_to_string(owner_name_ref); + if app_name.is_empty() { + continue; + } + + // kCGWindowName may be null without Screen Recording permission; + // fall back to the app name to keep the record useful. + let win_name_ref = CFDictionaryGetValue(dict, key_name); + let window_title = if win_name_ref.is_null() { + app_name.clone() + } else { + let t = cfstr_to_string(win_name_ref); + if t.is_empty() { + app_name.clone() + } else { + t + } + }; + + results.push(SecondaryWindowInfo { + app_name, + window_title, + monitor_id: if x_pos < 0 { 2 } else { 1 }, + }); + } + + CFRelease(list); + CFRelease(key_pid); + CFRelease(key_layer); + CFRelease(key_owner_name); + CFRelease(key_name); + CFRelease(key_bounds); + CFRelease(key_x); + + results + } } /// Poll visible windows on non-primary monitors (Linux). @@ -599,6 +968,7 @@ fn poll_active_window() -> Option { document_path: None, activated_at: unix_secs(), browser_title: None, + monitor_id: None, }) } } @@ -959,7 +1329,17 @@ fn run_poller(state: AppState, store: Arc) { let mut settings_gen = state.settings_generation.load(Ordering::Relaxed); loop { - std::thread::sleep(Duration::from_secs(1)); + // 3s cadence is fast enough for app-switch tracking (humans rarely + // bounce between windows faster than that) but ~3x cheaper than the + // old 1s wakeup. The cost adds up because every tick re-runs the + // platform active-window probe (Accessibility on macOS, X11/Wayland + // calls on Linux). + std::thread::sleep(Duration::from_secs(3)); + let tick_start = std::time::Instant::now(); + // Record the heartbeat before any early-continue branches so that + // "running but tracking disabled" still shows a live loop rather + // than a stale last_tick_unix_ms. + state.record_task_heartbeat("active-window-poll", 0); if !state.track_active_window.load(Ordering::Relaxed) { if let Some(snap) = snapshot.take() { @@ -1124,12 +1504,26 @@ fn run_poller(state: AppState, store: Arc) { if deleted > 0 { tracing::info!("[activity] pruned {deleted} file_interactions older than {retention_days}d"); } + + // Session day directories (EEG/PPG/IMU/fNIRS recordings, + // sidecars, metrics caches, per-day SQLite + HNSW). + let (dirs_removed, dir_errors) = + crate::session::retention::prune_session_dirs(&skill_dir, retention_days, now); + if dirs_removed > 0 || dir_errors > 0 { + tracing::info!( + "[activity] pruned {dirs_removed} session day dirs older than {retention_days}d ({dir_errors} errors)" + ); + } } // Build focus sessions from recent interactions. build_focus_sessions(&store, now.saturating_sub(7200)); // Reclaim space from pruned rows (incremental auto-vacuum). store.optimize(); } + // Update with the measured tick duration. The earlier no-op heartbeat + // already published a `last_tick_unix_ms`; this overwrite refines + // `last_duration_ms` for ticks that did real work. + state.record_task_heartbeat("active-window-poll", tick_start.elapsed().as_millis() as u64); } } @@ -2315,13 +2709,88 @@ mod tests { // ── Clipboard monitor (macOS only) ─────────────────────────────────────────── +/// Read NSPasteboard.changeCount via objc2 — increments every time the +/// pasteboard contents change, no IPC, no permission prompt. Replaces the +/// previous `osascript "the clipboard info"` which forked a subprocess every +/// 2 seconds even when nothing had been copied. +#[cfg(target_os = "macos")] +fn ns_pasteboard_change_count() -> Option { + use objc2_app_kit::NSPasteboard; + let pb = NSPasteboard::generalPasteboard(); + Some(pb.changeCount() as i64) +} + +/// Native (no-osascript, no-permission-prompt) read of pasteboard content +/// type and size. Returns `(content_type, content_size_bytes)` matching +/// the legacy osascript classifier so the activity store schema is stable. +/// +/// `content_type` is one of "image" | "file" | "text" — we don't need finer +/// granularity downstream and copying e.g. an RTF document still falls back +/// to "text" (the activity store doesn't distinguish text variants). +#[cfg(target_os = "macos")] +fn ns_pasteboard_classify() -> (&'static str, u64) { + use objc2_app_kit::{ + NSPasteboard, NSPasteboardTypeFileURL, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF, + }; + + let pb = NSPasteboard::generalPasteboard(); + + // `types()` returns the UTI list currently on the pasteboard, ordered by + // richness. We probe in the same order the old osascript classifier did: + // image first (PNG/TIFF), then file URL, then any text. Compare as + // Rust strings since NSPasteboardType is a typedef for NSString and + // we don't want to depend on objc2 protocol traits. + let Some(types) = pb.types() else { + return ("text", 0); + }; + + let mut have_png = false; + let mut have_tiff = false; + let mut have_file_url = false; + let mut have_string = false; + for i in 0..types.count() { + let item = types.objectAtIndex(i); + let s = item.to_string(); + match s.as_str() { + "public.png" => have_png = true, + "public.tiff" => have_tiff = true, + "public.file-url" => have_file_url = true, + "public.utf8-plain-text" => have_string = true, + _ => {} + } + } + + // SAFETY: NSPasteboardType* constants are static Objective-C string references + // defined by the framework; accessing them is always safe. + let (content_type, probe_type): (&'static str, Option<&objc2_app_kit::NSPasteboardType>) = unsafe { + if have_png { + ("image", Some(NSPasteboardTypePNG)) + } else if have_tiff { + ("image", Some(NSPasteboardTypeTIFF)) + } else if have_file_url { + ("file", Some(NSPasteboardTypeFileURL)) + } else if have_string { + ("text", Some(NSPasteboardTypeString)) + } else { + ("text", None) + } + }; + + let content_size = probe_type + .and_then(|t| pb.dataForType(t)) + .map(|d| d.length() as u64) + .unwrap_or(0); + + (content_type, content_size) +} + #[cfg(target_os = "macos")] fn run_clipboard_monitor(state: AppState, store: Arc) { let mut last_change_count: i64 = -1; - let mut permission_denied_until: u64 = 0; // backoff when permission denied loop { std::thread::sleep(Duration::from_secs(2)); + state.record_task_heartbeat("clipboard-monitor", 0); // Check if clipboard tracking is enabled. let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); @@ -2330,52 +2799,19 @@ fn run_clipboard_monitor(state: AppState, store: Arc) { continue; } - // Backoff when permission was recently denied (re-check every 60s). - let now = unix_secs(); - if now < permission_denied_until { - continue; - } - - // Query macOS pasteboard change count via osascript. - // If Automation permission is not granted, this will fail. - let out = match run_osascript("the clipboard info") { - Some(s) => s, - None => { - // Permission denied or osascript failed/timed out — back off for 60s. - permission_denied_until = now + 60; + // Cheap native gate: NSPasteboard.changeCount only changes when the + // pasteboard's contents change. While the user isn't copying, this + // is the only call we make. + if let Some(cc) = ns_pasteboard_change_count() { + if cc == last_change_count { continue; } - }; - - // The output looks like: {{«class utf8», 42}, {string, 42}} - // We hash the output to detect changes. - let hash = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - out.hash(&mut h); - h.finish() as i64 - }; - - if hash == last_change_count { - continue; + last_change_count = cc; } - last_change_count = hash; - // Determine content size from the info string. - let content_size: u64 = out - .split(',') - .filter_map(|s| s.trim().trim_end_matches('}').trim().parse::().ok()) - .next() - .unwrap_or(0); - - // Detect content type from clipboard info. - let content_type = if out.contains("«class PNGf»") || out.contains("TIFF") { - "image" - } else if out.contains("«class furl»") { - "file" - } else { - "text" - }; + // Native classification — no osascript, no Automation permission + // prompt, no subprocess fork even on copy events. + let (content_type, content_size) = ns_pasteboard_classify(); // Get current active window as the "source app". let source_app = poll_active_window().map(|w| w.app_name).unwrap_or_default(); @@ -2421,26 +2857,22 @@ fn run_clipboard_monitor(state: AppState, store: Arc) { /// Returns the path to the temp file, or None if extraction fails. #[cfg(target_os = "macos")] fn extract_clipboard_image_to_temp() -> Option { + use objc2_app_kit::{NSPasteboard, NSPasteboardTypePNG}; + let tmp_dir = std::env::temp_dir(); let tmp_path = tmp_dir.join(format!("skill_clipboard_{}.png", unix_secs())); - // Use osascript to write clipboard PNG data to a file. - let script = format!( - r#" - set pngData to the clipboard as «class PNGf» - set filePath to POSIX file "{}" - set fileRef to open for access filePath with write permission - write pngData to fileRef - close access fileRef - "#, - tmp_path.display() - ); - match run_osascript(&script) { - Some(_) if tmp_path.exists() => Some(tmp_path), - _ => { - let _ = std::fs::remove_file(&tmp_path); - None - } + + // Read PNG data straight from NSPasteboard — no osascript subprocess, + // no Apple Events permission prompt, no temp-file race. + let pb = NSPasteboard::generalPasteboard(); + // SAFETY: NSPasteboardTypePNG is a static Objective-C string constant defined by AppKit. + let data = pb.dataForType(unsafe { NSPasteboardTypePNG })?; + let bytes = data.to_vec(); + if bytes.is_empty() { + return None; } + std::fs::write(&tmp_path, bytes).ok()?; + Some(tmp_path) } /// Extract clipboard image data to a temporary PNG file (Windows). diff --git a/crates/skill-daemon/src/background.rs b/crates/skill-daemon/src/background.rs index ba326d29..6f80d4c0 100644 --- a/crates/skill-daemon/src/background.rs +++ b/crates/skill-daemon/src/background.rs @@ -8,7 +8,7 @@ use std::time::Duration; use chrono::{Datelike, Timelike}; use tracing::info; -use crate::routes::settings_io::load_user_settings; +use crate::routes::settings_io::{load_user_settings, patch_user_settings_sync}; use crate::state::AppState; /// Spawn all daemon background tasks. @@ -104,9 +104,10 @@ fn spawn_auto_connect(state: AppState) { skill_daemon_state::util::persist_paired_devices(&state); // Set as preferred. - let mut settings = load_user_settings(&state); - settings.preferred_id = Some(found.id.clone()); - crate::routes::settings_io::save_user_settings(&state, &settings); + let preferred_id = found.id.clone(); + patch_user_settings_sync(&state, move |s| { + s.preferred_id = Some(preferred_id); + }); info!("[auto-connect] device {} set as preferred", found.id); @@ -168,6 +169,7 @@ fn spawn_skills_sync(state: AppState) { tokio::time::sleep(Duration::from_secs(45)).await; let mut first_run = true; loop { + let tick_start = std::time::Instant::now(); let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); let settings = load_user_settings(&state); let interval_secs = settings.llm.tools.skills_refresh_interval_secs; @@ -200,6 +202,7 @@ fn spawn_skills_sync(state: AppState) { } } + state.record_task_heartbeat("skills-sync", tick_start.elapsed().as_millis() as u64); let sleep_secs = if interval_secs == 0 { 300 } else { diff --git a/crates/skill-daemon/src/calibration_runner.rs b/crates/skill-daemon/src/calibration_runner.rs index 80c2ffed..ba9b522e 100644 --- a/crates/skill-daemon/src/calibration_runner.rs +++ b/crates/skill-daemon/src/calibration_runner.rs @@ -21,7 +21,7 @@ use tokio::sync::oneshot; use tracing::info; use crate::routes::labels; -use crate::routes::settings_io::{load_user_settings, save_user_settings}; +use crate::routes::settings_io::{load_user_settings, patch_user_settings_sync}; use crate::state::AppState; use skill_daemon_state::CalibrationPhaseSnapshot; use skill_settings::CalibrationProfile; @@ -282,11 +282,13 @@ async fn run_session(state: AppState, profile: CalibrationProfile, mut cancel_rx info!("[calibration] session complete: {} loops", profile.loop_count); // Update last_calibration_utc in settings - let mut settings = load_user_settings(&state); - if let Some(p) = settings.calibration_profiles.iter_mut().find(|p| p.id == profile.id) { - p.last_calibration_utc = Some(unix_secs()); - } - save_user_settings(&state, &settings); + let profile_id = profile.id.clone(); + let completed_at = unix_secs(); + patch_user_settings_sync(&state, move |s| { + if let Some(p) = s.calibration_profiles.iter_mut().find(|p| p.id == profile_id) { + p.last_calibration_utc = Some(completed_at); + } + }); let snap = CalibrationPhaseSnapshot { kind: "done".into(), diff --git a/crates/skill-daemon/src/cmd_dispatch/data_cmds.rs b/crates/skill-daemon/src/cmd_dispatch/data_cmds.rs index 3e9bc21a..0496c977 100644 --- a/crates/skill-daemon/src/cmd_dispatch/data_cmds.rs +++ b/crates/skill-daemon/src/cmd_dispatch/data_cmds.rs @@ -439,6 +439,13 @@ pub(super) async fn cmd_sleep(state: &AppState, msg: &Value) -> Result Result { let query = str_field(msg, "query").ok_or("missing query")?; + // Empty/whitespace query would match every label via `text LIKE '%%'`, + // then loop search_embeddings_in_range per label across all daily DBs — + // tens of seconds of work that callers never actually want. Smoke test + // expects this to error out fast. + if query.trim().is_empty() { + return Err("empty query".into()); + } let k_text = u64_field(msg, "k_text").unwrap_or(5) as usize; let k_eeg = u64_field(msg, "k_eeg").unwrap_or(5) as usize; let k_labels = u64_field(msg, "k_labels").unwrap_or(3) as usize; @@ -584,19 +591,11 @@ pub(super) async fn cmd_hooks_set(state: &AppState, msg: &Value) -> Result { - if let Err(e) = std::fs::write(&path, json) { - return Err(format!("failed to save hooks: {e}")); - } - } - Err(e) => return Err(format!("failed to serialize settings: {e}")), - } - Ok(json!({ "hooks": settings.hooks })) + crate::routes::settings_io::patch_user_settings_sync(state, move |s| { + s.hooks = hooks; + }); + let hooks = state.hooks.lock().map(|g| g.clone()).unwrap_or_default(); + Ok(json!({ "hooks": hooks })) } pub(super) async fn cmd_hooks_suggest(state: &AppState, msg: &Value) -> Result { diff --git a/crates/skill-daemon/src/cmd_dispatch/llm_cmds.rs b/crates/skill-daemon/src/cmd_dispatch/llm_cmds.rs index 2b3a1a89..c54ce1d6 100644 --- a/crates/skill-daemon/src/cmd_dispatch/llm_cmds.rs +++ b/crates/skill-daemon/src/cmd_dispatch/llm_cmds.rs @@ -145,6 +145,7 @@ pub(super) async fn cmd_llm_add_model(state: &AppState, msg: &Value) -> Result Result Resu pub(super) async fn cmd_llm_set_autoload_mmproj(state: &AppState, msg: &Value) -> Result { let enabled = bool_field(msg, "enabled").ok_or("missing enabled")?; - let skill_dir = skill_dir(state); - let mut settings = skill_settings::load_settings(&skill_dir); - settings.llm.autoload_mmproj = enabled; - let path = skill_settings::settings_path(&skill_dir); - match serde_json::to_string_pretty(&settings) { - Ok(json) => { - if let Err(e) = std::fs::write(&path, json) { - return Err(format!("failed to save LLM settings: {e}")); - } - } - Err(e) => return Err(format!("failed to serialize settings: {e}")), - } + crate::routes::settings_io::patch_user_settings_sync(state, move |s| { + s.llm.autoload_mmproj = enabled; + }); Ok(json!({ "value": enabled })) } diff --git a/crates/skill-daemon/src/cmd_dispatch/system_cmds.rs b/crates/skill-daemon/src/cmd_dispatch/system_cmds.rs index 8d44b09b..84130d4b 100644 --- a/crates/skill-daemon/src/cmd_dispatch/system_cmds.rs +++ b/crates/skill-daemon/src/cmd_dispatch/system_cmds.rs @@ -169,10 +169,8 @@ pub(super) async fn cmd_health_metric_types(state: &AppState) -> Result Result { - let skill_dir = skill_dir(state); - let settings = skill_settings::load_settings(&skill_dir); - let has_token = !settings.device_api.oura_access_token.is_empty(); +pub(super) async fn cmd_oura_status(_state: &AppState) -> Result { + let has_token = !skill_settings::keychain::get_oura_access_token().is_empty(); Ok(json!({ "connected": has_token, "has_token": has_token, @@ -183,8 +181,7 @@ pub(super) async fn cmd_oura_sync(state: &AppState, msg: &Value) -> Result, channel_names: Vec, sample_rate: f32, @@ -62,6 +63,7 @@ impl EpochAccumulator { device_channels: device_channels.min(EEG_CHANNELS), hop_samples: hop, native_epoch_samples: native_epoch, + max_buf_samples: native_epoch * 4, device_name: None, channel_names, sample_rate, @@ -101,6 +103,20 @@ impl EpochAccumulator { self.bufs[electrode].extend(samples.iter().copied()); self.since_last[electrode] += samples.len(); + // Per-channel cap: when other electrodes stall, this channel would + // otherwise grow without bound (the drain step requires min_buf across + // all channels). Drop oldest down to one epoch's worth. + if self.bufs[electrode].len() > self.max_buf_samples { + let drop = self.bufs[electrode].len() - self.native_epoch_samples; + self.bufs[electrode].drain(..drop); + self.since_last[electrode] = self.since_last[electrode].min(self.native_epoch_samples); + info!( + channel = electrode, + dropped = drop, + "epoch buf overflow — channel imbalance, dropping oldest samples" + ); + } + let n_ch = self.device_channels; let native_epoch = self.native_epoch_samples; diff --git a/crates/skill-daemon/src/embed/device.rs b/crates/skill-daemon/src/embed/device.rs new file mode 100644 index 00000000..58b2786b --- /dev/null +++ b/crates/skill-daemon/src/embed/device.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Runtime RLX device resolution for EXG (EEG/ECG/EMG) inference. + +use rlx::Device; + +/// Resolve the best available RLX device for EXG inference. +/// +/// Respects the user's `Settings::exg_inference_device` preference string: +/// +/// | value | behaviour | +/// |----------|------------------------------------------------| +/// | `"auto"` | platform default (see below), CPU fallback | +/// | `"cpu"` | always CPU | +/// | `"metal"`| Apple Metal; CPU if unavailable | +/// | `"mlx"` | Apple MLX; CPU if unavailable | +/// | `"cuda"` | NVIDIA CUDA; CPU if unavailable | +/// | `"gpu"` | wgpu; CPU if unavailable | +/// | `"rocm"` | AMD ROCm; CPU if unavailable | +/// +/// Platform defaults for `"auto"`: +/// - **macOS**: Metal → MLX → CPU +/// - **Linux / Windows**: CUDA → wgpu → CPU +/// +/// `is_available` returns `true` only when the backend feature was +/// compiled in AND the hardware/driver probe passes (CUDA/wgpu). +/// Selecting a device that is unavailable silently falls back to CPU. +pub fn resolve_exg_device(pref: &str) -> Device { + use rlx::runtime::device_ext::is_available; + + match pref { + "cpu" => return Device::Cpu, + "metal" => { + return if is_available(Device::Metal) { + Device::Metal + } else { + Device::Cpu + } + } + "mlx" => { + return if is_available(Device::Mlx) { + Device::Mlx + } else { + Device::Cpu + } + } + "cuda" => { + return if is_available(Device::Cuda) { + Device::Cuda + } else { + Device::Cpu + } + } + "gpu" => { + return if is_available(Device::Gpu) { + Device::Gpu + } else { + Device::Cpu + } + } + "rocm" => { + return if is_available(Device::Rocm) { + Device::Rocm + } else { + Device::Cpu + } + } + _ => {} // "auto" or unknown — fall through to platform defaults + } + + #[cfg(target_os = "macos")] + { + if is_available(Device::Metal) { + return Device::Metal; + } + if is_available(Device::Mlx) { + return Device::Mlx; + } + } + #[cfg(not(target_os = "macos"))] + { + if is_available(Device::Cuda) { + return Device::Cuda; + } + if is_available(Device::Gpu) { + return Device::Gpu; + } + } + + Device::Cpu +} diff --git a/crates/skill-daemon/src/embed/mod.rs b/crates/skill-daemon/src/embed/mod.rs index a9fcc068..0bb44b83 100644 --- a/crates/skill-daemon/src/embed/mod.rs +++ b/crates/skill-daemon/src/embed/mod.rs @@ -8,8 +8,11 @@ mod accumulator; mod day_store; +mod device; mod worker; +pub(crate) use device::resolve_exg_device; + pub(crate) use accumulator::EpochAccumulator; pub(crate) use worker::EmbedWorkerHandle; pub(crate) use worker::{encode_raw_public, load_encoder_public, PublicEncoder}; diff --git a/crates/skill-daemon/src/embed/worker.rs b/crates/skill-daemon/src/embed/worker.rs index 3e6cc7d9..97a4ad6b 100644 --- a/crates/skill-daemon/src/embed/worker.rs +++ b/crates/skill-daemon/src/embed/worker.rs @@ -11,6 +11,8 @@ use std::sync::mpsc; use skill_daemon_common::EventEnvelope; use skill_eeg::eeg_model_config::{ExgModelBackend, ExgModelConfig}; +#[cfg(feature = "embed-eegdino")] +use skill_exg::eegdino::EegDino; #[cfg(feature = "embed-neurorvq")] use skill_exg::neurorvq::{Modality as NeuroModality, NeuroRVQFM}; use skill_settings::HookRule; @@ -364,7 +366,7 @@ fn embed_worker_main( } // Load encoder. - let encoder = load_encoder(&config, &skill_dir); + let mut encoder = load_encoder(&config, &skill_dir); // Initialize hook matcher. let mut hook_matcher = if hooks.iter().any(|h| h.enabled) { @@ -441,7 +443,7 @@ fn embed_worker_main( // Encode the epoch. let t0 = std::time::Instant::now(); - let embedding = encoder.as_ref().and_then(|enc| encode_epoch(enc, &msg)); + let embedding = encoder.as_mut().and_then(|enc| encode_epoch(enc, &msg)); let embed_ms = t0.elapsed().as_millis(); if embedding.is_none() && encoder.is_some() { @@ -519,58 +521,26 @@ fn embed_worker_main( pub(crate) enum Encoder { #[cfg(feature = "embed-zuna")] Zuna(Box), - #[cfg(feature = "embed-zuna-gpu")] - ZunaGpu(Box), - #[cfg(feature = "embed-zuna-gpu-f16")] - ZunaGpuF16(Box), - #[cfg(feature = "embed-zuna-mlx")] - ZunaMlx(Box), #[cfg(feature = "embed-luna")] - Luna(Box>), - #[cfg(feature = "embed-reve")] - Reve(Box>), - #[cfg(feature = "embed-osf")] - Osf(Box>), - #[cfg(feature = "embed-sleepfm")] - SleepFM(Box>), - #[cfg(feature = "embed-sleeplm")] - SleepLM(Box>), - #[cfg(feature = "embed-steegformer")] - STEEGFormer(Box>), + Luna(Box), #[cfg(feature = "embed-tribev2")] Tribev2(Box), #[cfg(feature = "embed-neurorvq")] NeuroRVQ(Box), + #[cfg(feature = "embed-eegdino")] + EegDino(Box), None, } #[cfg(feature = "embed-tribev2")] pub(crate) struct Tribev2State { - _placeholder: (), + #[allow(dead_code)] + encoder: tribev2::TribeRlx, } #[cfg(feature = "embed-zuna")] pub(crate) struct ZunaState { - encoder: zuna_rs::ZunaEncoder, - data_config: zuna_rs::config::DataConfig, -} - -#[cfg(feature = "embed-zuna-gpu")] -pub(crate) struct ZunaGpuState { - encoder: zuna_rs::ZunaEncoder, - data_config: zuna_rs::config::DataConfig, -} - -#[cfg(feature = "embed-zuna-gpu-f16")] -#[allow(dead_code)] -pub(crate) struct ZunaGpuF16State { - encoder: zuna_rs::ZunaEncoder>, - data_config: zuna_rs::config::DataConfig, -} - -#[cfg(feature = "embed-zuna-mlx")] -pub(crate) struct ZunaMlxState { - encoder: zuna_rs::ZunaEncoder, + encoder: zuna_rs::ZunaEncoder, data_config: zuna_rs::config::DataConfig, } @@ -579,6 +549,11 @@ pub(crate) struct NeuroRVQState { model: NeuroRVQFM, } +#[cfg(feature = "embed-eegdino")] +pub(crate) struct EegDinoState { + model: EegDino, +} + fn load_encoder(config: &ExgModelConfig, _skill_dir: &Path) -> Option { let device_pref = skill_settings::load_settings(_skill_dir).exg_inference_device; let backend = config.model_backend.clone(); @@ -587,7 +562,8 @@ fn load_encoder(config: &ExgModelConfig, _skill_dir: &Path) -> Option { #[cfg(feature = "embed-neurorvq")] ExgModelBackend::Neurorvq => { info!("loading NeuroRVQ encoder"); - match NeuroRVQFM::from_default_hf(NeuroModality::EEG) { + let device = super::resolve_exg_device(&device_pref); + match NeuroRVQFM::from_default_hf(NeuroModality::EEG, device) { Ok(model) => { info!("NeuroRVQ encoder loaded"); Some(Encoder::NeuroRVQ(Box::new(NeuroRVQState { model }))) @@ -603,46 +579,31 @@ fn load_encoder(config: &ExgModelConfig, _skill_dir: &Path) -> Option { warn!("NeuroRVQ backend selected but support is not compiled (enable feature: embed-neurorvq)"); None } - #[cfg(feature = "embed-zuna")] - ExgModelBackend::Zuna => { - info!(repo = %config.hf_repo, "loading ZUNA encoder"); - // Backend selection: auto → mlx (macOS) → gpu → cpu - // "auto" on macOS tries MLX first, then GPU, then CPU. - // "mlx" forces MLX only (macOS). - // "gpu" forces wgpu only. - // "cpu" skips all accelerated backends. - let _try_mlx = matches!(device_pref.as_str(), "auto" | "mlx"); - let try_gpu = matches!(device_pref.as_str(), "auto" | "gpu"); - - #[cfg(feature = "embed-zuna-mlx")] - if _try_mlx { - if let Some(s) = load_zuna_mlx(config) { - info!("ZUNA MLX encoder loaded"); - return Some(Encoder::ZunaMlx(Box::new(s))); - } - if device_pref == "mlx" { - warn!("MLX requested but unavailable, falling back to CPU"); - } else { - warn!("MLX unavailable, trying GPU wgpu"); - } - } - #[cfg(feature = "embed-zuna-gpu-f16")] - if try_gpu { - if let Some(s) = load_zuna_gpu_f16(config) { - info!("ZUNA GPU f16 encoder loaded"); - return Some(Encoder::ZunaGpuF16(Box::new(s))); + #[cfg(feature = "embed-eegdino")] + ExgModelBackend::Eegdino => { + info!(variant = %config.eegdino_variant, "loading EEG-DINO encoder"); + let device = super::resolve_exg_device(&device_pref); + match EegDino::from_hf(&config.eegdino_hf_repo, &config.eegdino_variant, device) { + Ok(model) => { + info!(dim = model.embed_dim(), "EEG-DINO encoder loaded"); + Some(Encoder::EegDino(Box::new(EegDinoState { model }))) } - warn!("GPU f16 unavailable, trying GPU f32"); - } - #[cfg(feature = "embed-zuna-gpu")] - if try_gpu { - if let Some(s) = load_zuna_gpu(config) { - info!("ZUNA GPU encoder loaded"); - return Some(Encoder::ZunaGpu(Box::new(s))); + Err(e) => { + warn!(%e, "EEG-DINO load failed — metrics-only"); + None } - warn!("GPU f32 unavailable, falling back to CPU"); } - load_zuna(config) + } + #[cfg(not(feature = "embed-eegdino"))] + ExgModelBackend::Eegdino => { + warn!("EEG-DINO backend selected but support is not compiled (enable feature: embed-eegdino)"); + None + } + #[cfg(feature = "embed-zuna")] + ExgModelBackend::Zuna => { + info!(repo = %config.hf_repo, "loading ZUNA encoder"); + let device = super::resolve_exg_device(&device_pref); + load_zuna(config, device) .map(|s| { info!("ZUNA encoder loaded"); Encoder::Zuna(Box::new(s)) @@ -654,57 +615,37 @@ fn load_encoder(config: &ExgModelConfig, _skill_dir: &Path) -> Option { } #[cfg(feature = "embed-luna")] ExgModelBackend::Luna => { + let device = super::resolve_exg_device(&device_pref); let wf = config.luna_weights_file(); - skill_exg::resolve_luna_weights(&config.luna_hf_repo, wf).and_then(|(w, c)| { - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - luna_rs::LunaEncoder::::load(&c, &w, device) - .ok() - .map(|(enc, _)| Encoder::Luna(Box::new(enc))) - }) + skill_exg::resolve_luna_weights(&config.luna_hf_repo, wf).and_then( + |(w, c)| match luna_rs::LunaEncoder::load(&c, &w, device) { + Ok((enc, ms)) => { + info!(ms, "LUNA encoder loaded"); + Some(Encoder::Luna(Box::new(enc))) + } + Err(e) => { + warn!(%e, "LUNA encoder load failed"); + None + } + }, + ) } - #[cfg(feature = "embed-reve")] - ExgModelBackend::Reve => resolve_catalog_hf("reve-base").and_then(|(w, c)| { - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - reve_rs::ReveEncoder::::load(&c, &w, device) - .ok() - .map(|(enc, _)| Encoder::Reve(Box::new(enc))) - }), - #[cfg(feature = "embed-osf")] - ExgModelBackend::Osf => resolve_catalog_hf("osf-base").and_then(|(w, c)| { - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - osf_rs::OsfEncoder::::load(&c, &w, device) - .ok() - .map(|(enc, _)| Encoder::Osf(Box::new(enc))) - }), - #[cfg(feature = "embed-sleepfm")] - ExgModelBackend::Sleepfm => resolve_catalog_hf("sleepfm").and_then(|(w, c)| { - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - sleepfm::SleepFmEncoder::::load(&c, &w, device) - .ok() - .map(|(enc, _)| Encoder::SleepFM(Box::new(enc))) - }), - #[cfg(feature = "embed-sleeplm")] - ExgModelBackend::Sleeplm => resolve_catalog_hf("sleeplm").and_then(|(w, c)| { - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - sleeplm::SleepLMEncoder::::load(&c, &w, device) - .ok() - .map(|(enc, _)| Encoder::SleepLM(Box::new(enc))) - }), - #[cfg(feature = "embed-steegformer")] - ExgModelBackend::Steegformer => resolve_catalog_hf("steegformer-base").and_then(|(w, c)| { - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - steegformer::STEEGFormerEncoder::::load(&c, &w, device) - .ok() - .map(|(enc, _)| Encoder::STEEGFormer(Box::new(enc))) - }), #[cfg(feature = "embed-tribev2")] ExgModelBackend::Tribev2 => { - // TRIBEv2 is a complex multimodal fMRI encoder. - // Weight loading is supported but runtime encoding requires - // the full modality pipeline. For now, report as available - // but fall through to metrics-only. - info!("TRIBEv2 weights can be resolved but runtime encoding is not yet integrated"); - None + let device = super::resolve_exg_device(&device_pref); + resolve_catalog_hf("tribev2").and_then(|(w, c)| { + match tribev2::TribeRlx::from_pretrained(c.to_str()?, w.to_str()?, None) { + Ok(enc) => { + let enc = enc.with_device(device); + info!("TRIBEv2 encoder loaded"); + Some(Encoder::Tribev2(Box::new(Tribev2State { encoder: enc }))) + } + Err(e) => { + warn!(%e, "TRIBEv2 encoder load failed"); + None + } + } + }) } #[allow(unreachable_patterns)] other => { @@ -741,18 +682,17 @@ fn resolve_catalog_hf(family_id: &str) -> Option<(std::path::PathBuf, std::path: } #[cfg(feature = "embed-zuna")] -fn load_zuna(config: &ExgModelConfig) -> Option { +fn load_zuna(config: &ExgModelConfig, device: rlx::Device) -> Option { match skill_exg::resolve_hf_weights(&config.hf_repo) { Some((weights_path, config_path)) => { info!(weights = %weights_path.display(), config = %config_path.display(), "ZUNA weights resolved"); - match zuna_rs::ZunaEncoder::::load( - &config_path, - &weights_path, - burn::backend::ndarray::NdArrayDevice::Cpu, - ) { + match zuna_rs::ZunaEncoder::load(&config_path, &weights_path, device) { Ok((encoder, ms)) => { info!(ms, "ZUNA encoder loaded"); - let data_config = encoder.data_cfg.clone(); + let data_config = zuna_rs::config::DataConfig { + num_fine_time_pts: encoder.model_cfg.input_dim, + ..Default::default() + }; Some(ZunaState { encoder, data_config }) } Err(e) => { @@ -771,43 +711,25 @@ fn load_zuna(config: &ExgModelConfig) -> Option { // ── Per-epoch encoding ────────────────────────────────────────────────────── -fn encode_epoch(encoder: &Encoder, msg: &EpochMsg) -> Option> { +fn encode_epoch(encoder: &mut Encoder, msg: &EpochMsg) -> Option> { match encoder { #[cfg(feature = "embed-zuna")] Encoder::Zuna(state) => encode_zuna(state, msg), - #[cfg(feature = "embed-zuna-gpu")] - Encoder::ZunaGpu(state) => encode_zuna_gpu(state, msg), - #[cfg(feature = "embed-zuna-gpu-f16")] - Encoder::ZunaGpuF16(state) => encode_zuna_gpu_f16(state, msg), - #[cfg(feature = "embed-zuna-mlx")] - Encoder::ZunaMlx(state) => encode_zuna_mlx(state, msg), #[cfg(feature = "embed-luna")] Encoder::Luna(enc) => encode_luna(enc, msg), - #[cfg(feature = "embed-reve")] - Encoder::Reve(enc) => encode_reve(enc, msg), - #[cfg(feature = "embed-osf")] - Encoder::Osf(enc) => encode_osf(enc, msg), - #[cfg(feature = "embed-sleepfm")] - Encoder::SleepFM(_enc) => { - // SleepFM requires PSG-specific tensor layout; use catalog embedding dim as fallback. - None - } - #[cfg(feature = "embed-sleeplm")] - Encoder::SleepLM(enc) => encode_sleeplm(enc, msg), - #[cfg(feature = "embed-steegformer")] - Encoder::STEEGFormer(enc) => encode_steegformer(enc, msg), #[cfg(feature = "embed-tribev2")] Encoder::Tribev2(state) => encode_tribev2(state, msg), #[cfg(feature = "embed-neurorvq")] Encoder::NeuroRVQ(state) => encode_neurorvq(state, msg), + #[cfg(feature = "embed-eegdino")] + Encoder::EegDino(state) => encode_eegdino(state, msg), #[allow(unreachable_patterns)] _ => None, } } #[cfg(feature = "embed-zuna")] -fn encode_zuna(state: &ZunaState, msg: &EpochMsg) -> Option> { - use std::collections::HashMap as HM; +fn encode_zuna(state: &mut ZunaState, msg: &EpochMsg) -> Option> { let n_ch = msg.channel_names.len().min(msg.samples.len()); if n_ch == 0 { return None; @@ -820,161 +742,93 @@ fn encode_zuna(state: &ZunaState, msg: &EpochMsg) -> Option> { } } let ch_names: Vec<&str> = msg.channel_names.iter().take(n_ch).map(String::as_str).collect(); - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - let empty_pos: HM = HM::new(); - let batches = zuna_rs::load_from_named_tensor::( + let empty_pos: HashMap = HashMap::new(); + let batches = zuna_rs::csv_loader::load_from_named_tensor( data, &ch_names, msg.sample_rate, 10.0, &empty_pos, &state.data_config, - &device, ) .ok()?; - let epochs = state.encoder.encode_batches(batches).ok()?; - epochs.first().map(|ep| { - let dim = ep.output_dim(); - let n_tok = ep.n_tokens(); + batches.into_iter().find_map(|ep| { + let emb = state + .encoder + .encode_one(&ep.eeg_tokens, &ep.tok_idx, &ep.chan_pos, ep.n_channels, ep.tc) + .ok()?; + let n_tok = emb.shape.first().copied().unwrap_or(0); + let dim = emb.shape.get(1).copied().unwrap_or(0); if dim == 0 || n_tok == 0 { - return Vec::new(); + return None; } let mut pooled = vec![0.0f32; dim]; for t in 0..n_tok { for (d, p) in pooled.iter_mut().enumerate() { - *p += ep.embeddings[t * dim + d]; + *p += emb.embeddings[t * dim + d]; } } - for v in &mut pooled { - *v /= n_tok as f32; + let inv = 1.0 / n_tok as f32; + for p in &mut pooled { + *p *= inv; } - pooled + Some(pooled) }) } #[cfg(feature = "embed-luna")] -fn encode_luna(enc: &luna_rs::LunaEncoder, msg: &EpochMsg) -> Option> { +fn encode_luna(enc: &mut luna_rs::LunaEncoder, msg: &EpochMsg) -> Option> { let n_ch = msg.channel_names.len().min(msg.samples.len()); if n_ch == 0 { return None; } let n_samples = msg.samples[0].len(); - // LUNA needs uppercase channel names from its vocabulary. - let mut luna_names: Vec = Vec::new(); - let mut luna_indices: Vec = Vec::new(); + // Filter to channels in LUNA's vocabulary; collect xyz positions and vocab indices. + let mut src_indices: Vec = Vec::new(); + let mut chan_pos: Vec = Vec::new(); + let mut vocab_indices: Vec = Vec::new(); for (idx, name) in msg.channel_names.iter().take(n_ch).enumerate() { let upper = name.to_uppercase(); - if luna_rs::channel_index(&upper).is_some() { - luna_names.push(upper); - luna_indices.push(idx); + if let Some(vi) = luna_rs::channel_index(&upper) { + let xyz = luna_rs::channel_positions::channel_xyz(&upper).unwrap_or([0.0, 0.0, 0.0]); + src_indices.push(idx); + chan_pos.extend_from_slice(&xyz); + vocab_indices.push(vi as i32); } } - if luna_names.is_empty() { + if src_indices.is_empty() { return None; } - let flat: Vec = luna_indices + let valid_ch = src_indices.len(); + let flat: Vec = src_indices .iter() .flat_map(|&ch| msg.samples[ch].iter().copied()) .collect(); - let ch_refs: Vec<&str> = luna_names.iter().map(String::as_str).collect(); - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - let batch = luna_rs::build_batch_named::(flat, &ch_refs, n_samples, &device); - let ep = enc.run_batch(&batch).ok()?; + let ep = enc + .run_epoch(&flat, &chan_pos, Some(&vocab_indices), valid_ch, n_samples) + .ok()?; Some(ep.output) } -#[cfg(feature = "embed-reve")] -fn encode_reve(enc: &reve_rs::ReveEncoder, msg: &EpochMsg) -> Option> { - let n_ch = msg.channel_names.len().min(msg.samples.len()); - if n_ch == 0 { - return None; - } - let n_samples = msg.samples[0].len(); - let flat: Vec = (0..n_ch).flat_map(|ch| msg.samples[ch].iter().copied()).collect(); - let positions = vec![0.0f32; n_ch * 3]; - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - let batch = reve_rs::build_batch::(flat, positions, n_ch, n_samples, &device); - let result = enc.run_batch(&batch).ok()?; - Some(result.output) -} - -#[cfg(feature = "embed-osf")] -fn encode_osf(enc: &osf_rs::OsfEncoder, msg: &EpochMsg) -> Option> { - let n_ch = msg.channel_names.len().min(msg.samples.len()); - if n_ch == 0 { - return None; - } - let n_samples = msg.samples[0].len(); - let flat: Vec = (0..n_ch).flat_map(|ch| msg.samples[ch].iter().copied()).collect(); - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - let batch = osf_rs::build_batch::(flat, n_ch, n_samples, &device); - let ep = enc.run_batch(&batch).ok()?; - Some(ep.cls_emb) -} - -#[cfg(feature = "embed-sleeplm")] -fn encode_sleeplm(enc: &sleeplm::SleepLMEncoder, msg: &EpochMsg) -> Option> { - // SleepLM expects [10, 1920] (10 PSG channels × 1920 samples). - // Pad/truncate the EEG signal to fit. - let n_ch = msg.channel_names.len().min(msg.samples.len()).min(10); - if n_ch == 0 { - return None; - } - let target_samples = 1920; - let mut flat = vec![0.0f32; 10 * target_samples]; - for ch in 0..n_ch { - let src = &msg.samples[ch]; - let copy_len = src.len().min(target_samples); - for (i, &v) in src.iter().take(copy_len).enumerate() { - flat[ch * target_samples + i] = v; - } - } - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - let batch = sleeplm::build_batch::(flat, &device); - let ep = enc.encode(&batch).ok()?; - Some(ep.embedding) +#[cfg(feature = "embed-tribev2")] +fn encode_tribev2(_state: &mut Tribev2State, _msg: &EpochMsg) -> Option> { + // TRIBEv2 takes multimodal fMRI features (text/audio/video), not EEG epochs. + None } -#[cfg(feature = "embed-steegformer")] -fn encode_steegformer( - enc: &steegformer::STEEGFormerEncoder, - msg: &EpochMsg, -) -> Option> { +#[cfg(feature = "embed-eegdino")] +fn encode_eegdino(state: &mut EegDinoState, msg: &EpochMsg) -> Option> { let n_ch = msg.channel_names.len().min(msg.samples.len()); if n_ch == 0 { return None; } - let n_samples = msg.samples[0].len(); - // ST-EEGFormer has a channel vocabulary like LUNA. - let mut names: Vec = Vec::new(); - let mut indices: Vec = Vec::new(); - for (idx, name) in msg.channel_names.iter().take(n_ch).enumerate() { - let upper = name.to_uppercase(); - if steegformer::channel_index(&upper).is_some() { - names.push(upper); - indices.push(idx); - } - } - if names.is_empty() { - return None; - } - let flat: Vec = indices.iter().flat_map(|&ch| msg.samples[ch].iter().copied()).collect(); - let ch_refs: Vec<&str> = names.iter().map(String::as_str).collect(); - let device = burn::backend::ndarray::NdArrayDevice::Cpu; - let batch = steegformer::build_batch_named::(flat, &ch_refs, n_samples, &device); - let ep = enc.run_batch(&batch).ok()?; - Some(ep.output) -} - -#[cfg(feature = "embed-tribev2")] -fn encode_tribev2(_state: &Tribev2State, _msg: &EpochMsg) -> Option> { - // TRIBEv2 runtime encoding requires the full multimodal pipeline. - // Not yet integrated — weights are downloaded but encoding is pending. - None + let ch_names: Vec<&str> = msg.channel_names.iter().take(n_ch).map(String::as_str).collect(); + let samples: Vec> = msg.samples.iter().take(n_ch).cloned().collect(); + state.model.encode_pooled(&samples, &ch_names).ok() } #[cfg(feature = "embed-neurorvq")] -fn encode_neurorvq(state: &NeuroRVQState, msg: &EpochMsg) -> Option> { +fn encode_neurorvq(state: &mut NeuroRVQState, msg: &EpochMsg) -> Option> { let n_ch = msg.channel_names.len().min(msg.samples.len()); if n_ch == 0 { return None; @@ -990,283 +844,19 @@ fn encode_neurorvq(state: &NeuroRVQState, msg: &EpochMsg) -> Option> { state.model.encode_pooled(&signal, &ch_names).ok() } -// ── GPU ZUNA encoder ──────────────────────────────────────────────────────── - -#[cfg(feature = "embed-zuna-gpu")] -fn load_zuna_gpu(config: &ExgModelConfig) -> Option { - match skill_exg::resolve_hf_weights(&config.hf_repo) { - Some((weights_path, config_path)) => { - info!(weights = %weights_path.display(), "loading ZUNA encoder on GPU (wgpu/Metal)"); - let device = burn::backend::wgpu::WgpuDevice::default(); - match zuna_rs::ZunaEncoder::::load(&config_path, &weights_path, device) { - Ok((encoder, ms)) => { - info!(ms, "ZUNA GPU encoder loaded"); - let data_config = encoder.data_cfg.clone(); - Some(ZunaGpuState { encoder, data_config }) - } - Err(e) => { - warn!(%e, "ZUNA GPU encoder load failed — will fall back to CPU"); - None - } - } - } - None => { - warn!("ZUNA weights not found for GPU encoder"); - None - } - } -} - -#[cfg(feature = "embed-zuna-gpu")] -fn encode_zuna_gpu(state: &ZunaGpuState, msg: &EpochMsg) -> Option> { - use std::collections::HashMap as HM; - let n_ch = msg.channel_names.len().min(msg.samples.len()); - if n_ch == 0 { - return None; - } - let n_samples = msg.samples[0].len(); - let mut data = ndarray::Array2::::zeros((n_ch, n_samples)); - for (ch, samples) in msg.samples.iter().enumerate().take(n_ch) { - for (s, &v) in samples.iter().enumerate() { - data[[ch, s]] = v; - } - } - let ch_names: Vec<&str> = msg.channel_names.iter().take(n_ch).map(String::as_str).collect(); - let device = burn::backend::wgpu::WgpuDevice::default(); - let empty_pos: HM = HM::new(); - let batches = zuna_rs::load_from_named_tensor::( - data, - &ch_names, - msg.sample_rate, - 10.0, - &empty_pos, - &state.data_config, - &device, - ) - .ok()?; - let epochs = state.encoder.encode_batches(batches).ok()?; - epochs.first().map(|ep| { - let dim = ep.output_dim(); - let n_tok = ep.n_tokens(); - if dim == 0 || n_tok == 0 { - return Vec::new(); - } - let mut pooled = vec![0.0f32; dim]; - for t in 0..n_tok { - for (d, p) in pooled.iter_mut().enumerate() { - *p += ep.embeddings[t * dim + d]; - } - } - let inv = 1.0 / n_tok as f32; - for p in &mut pooled { - *p *= inv; - } - pooled - }) -} - -// ── GPU f16 ZUNA encoder ──────────────────────────────────────────────────── -// Currently unused for batch reembed (burn f16→f32 extraction bug). -// Kept for future use when zuna-rs/burn fix the TypeMismatch issue. - -#[cfg(feature = "embed-zuna-gpu-f16")] -#[allow(dead_code)] -fn load_zuna_gpu_f16(config: &ExgModelConfig) -> Option { - match skill_exg::resolve_hf_weights(&config.hf_repo) { - Some((weights_path, config_path)) => { - info!(weights = %weights_path.display(), "loading ZUNA encoder on GPU f16 (wgpu/Metal half-precision)"); - let device = burn::backend::wgpu::WgpuDevice::default(); - match zuna_rs::ZunaEncoder::>::load( - &config_path, - &weights_path, - device, - ) { - Ok((encoder, ms)) => { - info!(ms, "ZUNA GPU f16 encoder loaded"); - let data_config = encoder.data_cfg.clone(); - Some(ZunaGpuF16State { encoder, data_config }) - } - Err(e) => { - warn!(%e, "ZUNA GPU f16 encoder load failed — will try f32 or CPU"); - None - } - } - } - None => { - warn!("ZUNA weights not found for GPU f16 encoder"); - None - } - } -} - -#[cfg(feature = "embed-zuna-gpu-f16")] -#[allow(dead_code)] -fn encode_zuna_gpu_f16(state: &ZunaGpuF16State, msg: &EpochMsg) -> Option> { - use std::collections::HashMap as HM; - let n_ch = msg.channel_names.len().min(msg.samples.len()); - if n_ch == 0 { - return None; - } - let n_samples = msg.samples[0].len(); - let mut data = ndarray::Array2::::zeros((n_ch, n_samples)); - for (ch, samples) in msg.samples.iter().enumerate().take(n_ch) { - for (s, &v) in samples.iter().enumerate() { - data[[ch, s]] = v; - } - } - let ch_names: Vec<&str> = msg.channel_names.iter().take(n_ch).map(String::as_str).collect(); - let device = burn::backend::wgpu::WgpuDevice::default(); - let empty_pos: HM = HM::new(); - let batches = zuna_rs::load_from_named_tensor::>( - data, - &ch_names, - msg.sample_rate, - 10.0, - &empty_pos, - &state.data_config, - &device, - ) - .ok()?; - let epochs = match state.encoder.encode_batches(batches) { - Ok(e) => e, - Err(e) => { - tracing::warn!("[encode-gpu-f16] encode_batches failed: {e}"); - return None; - } - }; - epochs.first().map(|ep| { - let dim = ep.output_dim(); - let n_tok = ep.n_tokens(); - if dim == 0 || n_tok == 0 { - return Vec::new(); - } - let mut pooled = vec![0.0f32; dim]; - for t in 0..n_tok { - for (d, p) in pooled.iter_mut().enumerate() { - *p += ep.embeddings[t * dim + d]; - } - } - let inv = 1.0 / n_tok as f32; - for p in &mut pooled { - *p *= inv; - } - pooled - }) -} - -// ── MLX ZUNA encoder ────────────────────────────────────────────────────────── - -#[cfg(feature = "embed-zuna-mlx")] -fn load_zuna_mlx(config: &ExgModelConfig) -> Option { - match skill_exg::resolve_hf_weights(&config.hf_repo) { - Some((weights_path, config_path)) => { - info!(weights = %weights_path.display(), "loading ZUNA encoder on MLX (Apple Silicon)"); - let device = burn_mlx::MlxDevice::default(); - match zuna_rs::ZunaEncoder::::load(&config_path, &weights_path, device) { - Ok((encoder, ms)) => { - info!(ms, "ZUNA MLX encoder loaded"); - let data_config = encoder.data_cfg.clone(); - Some(ZunaMlxState { encoder, data_config }) - } - Err(e) => { - warn!(%e, "ZUNA MLX encoder load failed — will try wgpu or CPU"); - None - } - } - } - None => { - warn!("ZUNA weights not found for MLX encoder"); - None - } - } -} - -#[cfg(feature = "embed-zuna-mlx")] -fn encode_zuna_mlx(state: &ZunaMlxState, msg: &EpochMsg) -> Option> { - use std::collections::HashMap as HM; - let n_ch = msg.channel_names.len().min(msg.samples.len()); - if n_ch == 0 { - return None; - } - let n_samples = msg.samples[0].len(); - let mut data = ndarray::Array2::::zeros((n_ch, n_samples)); - for (ch, samples) in msg.samples.iter().enumerate().take(n_ch) { - for (s, &v) in samples.iter().enumerate() { - data[[ch, s]] = v; - } - } - let ch_names: Vec<&str> = msg.channel_names.iter().take(n_ch).map(String::as_str).collect(); - let device = burn_mlx::MlxDevice::default(); - let empty_pos: HM = HM::new(); - let batches = zuna_rs::load_from_named_tensor::( - data, - &ch_names, - msg.sample_rate, - 10.0, - &empty_pos, - &state.data_config, - &device, - ) - .ok()?; - let epochs = state.encoder.encode_batches(batches).ok()?; - epochs.first().map(|ep| { - let dim = ep.output_dim(); - let n_tok = ep.n_tokens(); - if dim == 0 || n_tok == 0 { - return Vec::new(); - } - let mut pooled = vec![0.0f32; dim]; - for t in 0..n_tok { - for (d, p) in pooled.iter_mut().enumerate() { - *p += ep.embeddings[t * dim + d]; - } - } - let inv = 1.0 / n_tok as f32; - for p in &mut pooled { - *p *= inv; - } - pooled - }) -} - // ── Public API for batch reembedding ───────────────────────────────────────── -/// Opaque encoder for batch reembed — GPU f32 or CPU. -/// -/// GPU f16 is intentionally excluded: burn's `TensorData::to_vec::()` -/// has a TypeMismatch bug when the wgpu backend uses half-precision floats, -/// so embeddings cannot be extracted. Real-time streaming uses f16 fine -/// because it goes through a different code path. pub enum PublicEncoder { - Cpu(Encoder), - #[cfg(feature = "embed-zuna-gpu")] - Gpu(Box), + Inner(Encoder), } -/// Load an encoder for batch reembed: tries GPU f32 → CPU. -/// -/// GPU f16 is intentionally skipped for batch reembed because burn's -/// `TensorData::to_vec::()` has a TypeMismatch bug when the backend -/// uses half-precision floats — the embeddings cannot be extracted as f32. -/// Real-time streaming uses f16 fine because it goes through a different -/// code path that doesn't extract to Vec. pub fn load_encoder_public(config: &ExgModelConfig, skill_dir: &Path) -> Option { - if matches!(config.model_backend, ExgModelBackend::Zuna) { - // GPU f32 — works correctly with TensorData extraction. - #[cfg(feature = "embed-zuna-gpu")] - { - if let Some(gpu) = load_zuna_gpu(config) { - return Some(PublicEncoder::Gpu(Box::new(gpu))); - } - warn!("GPU f32 unavailable, falling back to CPU"); - } - } - load_encoder(config, skill_dir).map(PublicEncoder::Cpu) + load_encoder(config, skill_dir).map(PublicEncoder::Inner) } /// Encode raw EEG samples into an embedding vector. pub fn encode_raw_public( - encoder: &PublicEncoder, + encoder: &mut PublicEncoder, samples: &[Vec], channel_names: &[String], sample_rate: f64, @@ -1280,8 +870,6 @@ pub fn encode_raw_public( device_name: None, }; match encoder { - PublicEncoder::Cpu(enc) => encode_epoch(enc, &msg), - #[cfg(feature = "embed-zuna-gpu")] - PublicEncoder::Gpu(gpu) => encode_zuna_gpu(gpu, &msg), + PublicEncoder::Inner(enc) => encode_epoch(enc, &msg), } } diff --git a/crates/skill-daemon/src/handlers.rs b/crates/skill-daemon/src/handlers.rs index c4068ba6..513d55e8 100644 --- a/crates/skill-daemon/src/handlers.rs +++ b/crates/skill-daemon/src/handlers.rs @@ -8,14 +8,17 @@ use axum::{ Json, }; use base64::Engine as _; +use futures::{SinkExt, StreamExt}; use skill_daemon_common::{ DiscoveredDeviceResponse, EventEnvelope, ForgetDeviceRequest, HealthResponse, LslDiscoveredStreamResponse, PairDeviceRequest, ScannerCortexConfigRequest, ScannerStateResponse, ScannerWifiConfigRequest, SessionControlRequest, SetPreferredDeviceRequest, StatusResponse, VersionResponse, WsClient, WsPortResponse, WsRequestLog, DAEMON_NAME, PROTOCOL_VERSION, }; +use std::collections::HashSet; use std::net::SocketAddr; -use tokio::sync::{broadcast, oneshot}; +use std::sync::{Arc, RwLock as StdRwLock}; +use tokio::sync::{broadcast, mpsc, oneshot}; use tracing::error; use crate::state::AppState; @@ -735,9 +738,10 @@ pub(crate) async fn set_preferred_device( Json(req): Json, ) -> Json> { // Persist preferred_id to settings (synchronous so the response reflects it). - let mut settings = crate::routes::settings_io::load_user_settings(&state); - settings.preferred_id = if req.id.is_empty() { None } else { Some(req.id.clone()) }; - crate::routes::settings_io::save_user_settings(&state, &settings); + let preferred_id = if req.id.is_empty() { None } else { Some(req.id.clone()) }; + crate::routes::settings_io::patch_user_settings_sync(&state, move |s| { + s.preferred_id = preferred_id; + }); // Also update in-memory devices for consistency. if let Ok(mut guard) = state.devices.lock() { @@ -982,9 +986,10 @@ pub(crate) async fn control_retry_connect(State(state): State) -> Json persist_paired_devices(&state); // Set as preferred. - let mut settings = crate::routes::settings_io::load_user_settings(&state); - settings.preferred_id = Some(dev.id.clone()); - crate::routes::settings_io::save_user_settings(&state, &settings); + let preferred_id = dev.id.clone(); + crate::routes::settings_io::patch_user_settings_sync(&state, move |s| { + s.preferred_id = Some(preferred_id); + }); state.broadcast("devices-updated", serde_json::json!({ "auto_paired": dev.id })); @@ -1111,20 +1116,13 @@ pub(crate) async fn control_start_session( // would attempt a BLE scan even though the user plugged in a USB dongle. if let Some(ref t) = target { if let Some(port) = t.strip_prefix("usb:") { - let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let mut settings = skill_settings::load_settings(&skill_dir); - settings.openbci.serial_port = port.to_string(); - // A usb: target always means a serial board (Cyton or CytonDaisy). - // Preserve the user's choice if it is already a serial board; - // otherwise reset to Cyton so we never attempt a BLE / WiFi / - // UDP connection when a dongle is plugged in. - if !settings.openbci.board.is_serial() { - settings.openbci.board = skill_settings::OpenBciBoard::Cyton; - } - let path = skill_settings::settings_path(&skill_dir); - if let Ok(json) = serde_json::to_string_pretty(&settings) { - let _ = std::fs::write(path, json); - } + let port = port.to_string(); + crate::routes::settings_io::patch_user_settings_sync(&state, move |s| { + s.openbci.serial_port = port; + if !s.openbci.board.is_serial() { + s.openbci.board = skill_settings::OpenBciBoard::Cyton; + } + }); } if target_requires_pairing(t) && !is_paired_target(&state, t) { if let Ok(mut status) = state.status.lock() { @@ -1527,17 +1525,62 @@ pub(crate) async fn cmd_tunnel_root( Json(crate::cmd_dispatch::dispatch(state, msg).await) } -pub(crate) async fn handle_ws(mut socket: WebSocket, mut rx: broadcast::Receiver, state: AppState) { +/// Event types whose volume is high enough to overwhelm the WS sender path +/// (Muse @ 256 Hz produces ~300–500 frames/sec across these). They are +/// dropped by default and only forwarded when the client explicitly opts in +/// via `{command:"subscribe",events:[...]}`. See `handle_ws` for details. +pub(crate) const HIGH_RATE_EVENT_TYPES: &[&str] = &[ + "EegSample", + "EegBands", + "ImuSample", + "PpgSample", + "FnirsSample", + "SignalQuality", +]; + +pub(crate) async fn handle_ws(socket: WebSocket, mut rx: broadcast::Receiver, state: AppState) { + // Split the socket so the sender half can drain (events + responses) + // concurrently with the receiver half pulling commands off the wire. + // + // The original design ran `socket.send` and `socket.recv` in the same + // `tokio::select!` arm-set. When a device is streaming (Muse @ 256 Hz + // produces ~300–500 EegSample events/sec via the broadcast channel), + // the event arm wins repeatedly and `socket.send().await` keeps the + // task busy — incoming commands queued up and timed out client-side. + // + // Two-queue split: + // - `tx_resp` (unbounded): command responses + LLM chat deltas. + // Drained first (biased select) so responses jump ahead of any + // queued event backlog. Unbounded because responses are rare and + // small; bounding here can dead-end a command if events are + // filling the priority queue. + // - `tx_evt` (mpsc 64): broadcast events. `try_send` — dropped if + // the socket is backed up. + // - Dispatch runs in a spawned task per command so a slow handler + // (`umap`, `sessions`) doesn't block subsequent commands. + // + // Subscribe protocol: + // - Default: only low-rate control events (DaemonStarted, StatusUpdate, + // Battery, label/session lifecycle, etc.) are forwarded. + // - `{command:"subscribe",events:["EegSample","SignalQuality"]}` opts + // a specific event type in. `events:["*"]` opts into all known + // high-rate types at once. + // - `{command:"unsubscribe",events:[…]}` removes types; an empty + // array or `["*"]` clears the whole subscription set. + // - Response: `{command:"subscribe",ok:true,subscribed:[…]}`. + let subscribed: Arc>> = Arc::new(StdRwLock::new(HashSet::new())); + + let (mut sender, mut receiver) = socket.split(); + let connected = EventEnvelope { r#type: "DaemonStarted".to_string(), ts_unix_ms: now_unix_ms(), correlation_id: None, payload: serde_json::json!({ "message": "connected" }), }; - match serde_json::to_string(&connected) { Ok(payload) => { - if socket.send(Message::Text(payload.into())).await.is_err() { + if sender.send(Message::Text(payload.into())).await.is_err() { return; } } @@ -1547,94 +1590,189 @@ pub(crate) async fn handle_ws(mut socket: WebSocket, mut rx: broadcast::Receiver } } - // Channel for streaming messages back to the WS client. - // Used by LLM chat streaming to send incremental deltas. - let (_stream_tx, mut stream_rx) = tokio::sync::mpsc::channel::(64); - #[cfg(feature = "llm")] - let stream_tx = _stream_tx; - - loop { - tokio::select! { - // Broadcast events → send to client - event = rx.recv() => { - match event { - Ok(ev) => { - let payload = match serde_json::to_string(&ev) { - Ok(v) => v, - Err(err) => { - error!(%err, "failed to serialize websocket event"); - continue; - } - }; - if socket.send(Message::Text(payload.into())).await.is_err() { - break; - } + let (tx_resp, mut rx_resp) = mpsc::unbounded_channel::(); + let (tx_evt, mut rx_evt) = mpsc::channel::(64); + + // Sender task: drain responses with priority, then events. + let send_task = tokio::spawn(async move { + loop { + tokio::select! { + biased; + Some(msg) = rx_resp.recv() => { + if sender.send(Message::Text(msg.into())).await.is_err() { + break; } - Err(broadcast::error::RecvError::Lagged(skipped)) => { - tracing::debug!(%skipped, "websocket client lagged behind event stream"); + } + Some(msg) = rx_evt.recv() => { + if sender.send(Message::Text(msg.into())).await.is_err() { + break; } - Err(broadcast::error::RecvError::Closed) => break, } + else => break, } - // Streaming messages (from LLM chat) → send to client - Some(msg_str) = stream_rx.recv() => { - if socket.send(Message::Text(msg_str.into())).await.is_err() { - break; + } + }); + + // Event forwarder: broadcast → tx_evt with try_send (lossy by design). + // + // High-rate per-sample events (`EegSample`, `EegBands`, `ImuSample`, + // `PpgSample`, `FnirsSample`, `SignalQuality`) are gated behind the + // per-connection subscribed set. Low-rate control events (status, + // battery, label/session lifecycle, etc.) are always forwarded. + // + // Without this gate, a Muse @ 256 Hz produces 300–500 frames/sec and + // fills the kernel TCP send buffer faster than smoke tests / CLIs + // drain it; once `sender.send(event).await` blocks, even the priority + // response queue (rx_resp) can't get through. + fn is_high_rate_event(ty: &str) -> bool { + HIGH_RATE_EVENT_TYPES.contains(&ty) + } + let subscribed_for_evt = subscribed.clone(); + let evt_task = tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(ev) => { + if is_high_rate_event(&ev.r#type) { + // Cheap read lock — RwLock is std::sync (sub-µs reads). + let subs = match subscribed_for_evt.read() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + if !subs.contains(&ev.r#type) { + continue; + } + } + let payload = match serde_json::to_string(&ev) { + Ok(v) => v, + Err(err) => { + error!(%err, "failed to serialize websocket event"); + continue; + } + }; + let _ = tx_evt.try_send(payload); } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::debug!(%skipped, "websocket client lagged behind event stream"); + } + Err(broadcast::error::RecvError::Closed) => break, } - // Incoming messages from client → dispatch as commands - msg = socket.recv() => { - match msg { - Some(Ok(Message::Text(text))) => { - let text_str: &str = &text; - if let Ok(cmd) = serde_json::from_str::(text_str) { - let cmd_name = cmd.get("command") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - if cmd_name == "llm_chat" { - // LLM chat uses streaming: send deltas incrementally. - // Spawned as a separate task so that ARM 2 of the select! - // loop (stream_rx.recv()) can drain the mpsc channel and - // forward delta tokens to the socket concurrently with - // inference. Without the spawn the select! loop is blocked - // for the entire generation, stream_rx is never polled, and - // blocking_send deadlocks once the 64-slot buffer is full. - #[cfg(feature = "llm")] - { - let mut tx = stream_tx.clone(); - let state2 = state.clone(); - tokio::spawn(async move { - crate::cmd_dispatch::dispatch_llm_chat_streaming( - state2, cmd, &mut tx, - ).await; - }); - } - #[cfg(not(feature = "llm"))] - { - let response = crate::cmd_dispatch::dispatch(state.clone(), cmd).await; - if let Ok(resp_str) = serde_json::to_string(&response) { - if socket.send(Message::Text(resp_str.into())).await.is_err() { - break; - } - } + } + }); + + // Receiver loop: dispatch commands, push responses into the priority queue. + while let Some(msg) = receiver.next().await { + match msg { + Ok(Message::Text(text)) => { + let cmd: serde_json::Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + let cmd_name = cmd + .get("command") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + if cmd_name.is_empty() { + continue; + } + + // ── subscribe / unsubscribe (per-connection, in-band) ── + // Handled inline (not via cmd_dispatch) because the + // subscription set is per-WS state, not global daemon state. + if cmd_name == "subscribe" || cmd_name == "unsubscribe" { + let events: Vec = cmd + .get("events") + .and_then(serde_json::Value::as_array) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let mut subs = match subscribed.write() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let wants_all = events.iter().any(|e| e == "*"); + if cmd_name == "subscribe" { + if wants_all { + for ty in HIGH_RATE_EVENT_TYPES { + subs.insert((*ty).to_string()); + } + } else { + for ev in events { + if HIGH_RATE_EVENT_TYPES.contains(&ev.as_str()) { + subs.insert(ev); } - } else if !cmd_name.is_empty() { - let response = crate::cmd_dispatch::dispatch(state.clone(), cmd).await; - if let Ok(resp_str) = serde_json::to_string(&response) { - if socket.send(Message::Text(resp_str.into())).await.is_err() { - break; - } + } + } + } else if events.is_empty() || wants_all { + subs.clear(); + } else { + for ev in &events { + subs.remove(ev); + } + } + let mut current: Vec = subs.iter().cloned().collect(); + drop(subs); + current.sort(); + let response = serde_json::json!({ + "command": cmd_name, + "ok": true, + "subscribed": current, + }); + if let Ok(resp_str) = serde_json::to_string(&response) { + if tx_resp.send(resp_str).is_err() { + break; + } + } + continue; + } + + if cmd_name == "llm_chat" { + #[cfg(feature = "llm")] + { + // dispatch_llm_chat_streaming expects an mpsc::Sender; + // adapt unbounded → bounded with a thin shim that forwards + // deltas (deltas are small + rare, can't realistically + // saturate the unbounded channel). + let (mut tx_shim, mut rx_shim) = mpsc::channel::(64); + let tx_resp2 = tx_resp.clone(); + tokio::spawn(async move { + while let Some(s) = rx_shim.recv().await { + if tx_resp2.send(s).is_err() { + break; } } + }); + let state2 = state.clone(); + tokio::spawn(async move { + crate::cmd_dispatch::dispatch_llm_chat_streaming(state2, cmd, &mut tx_shim).await; + }); + } + #[cfg(not(feature = "llm"))] + { + let response = crate::cmd_dispatch::dispatch(state.clone(), cmd).await; + if let Ok(resp_str) = serde_json::to_string(&response) { + if tx_resp.send(resp_str).is_err() { + break; + } } } - Some(Ok(Message::Close(_))) | None => break, - _ => {} + } else { + let state2 = state.clone(); + let tx = tx_resp.clone(); + tokio::spawn(async move { + let response = crate::cmd_dispatch::dispatch(state2, cmd).await; + if let Ok(resp_str) = serde_json::to_string(&response) { + let _ = tx.send(resp_str); + } + }); } } + Ok(Message::Close(_)) | Err(_) => break, + _ => {} } } + + evt_task.abort(); + send_task.abort(); } #[cfg(test)] diff --git a/crates/skill-daemon/src/idle_reembed.rs b/crates/skill-daemon/src/idle_reembed.rs index cdc4a40f..827c1811 100644 --- a/crates/skill-daemon/src/idle_reembed.rs +++ b/crates/skill-daemon/src/idle_reembed.rs @@ -6,12 +6,104 @@ //! processing un-embedded epochs in the background. Immediately pauses //! when a device reconnects (real-time embedding takes priority). -use std::sync::atomic::Ordering; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; use std::time::{Duration, Instant}; use tracing::{info, warn}; -use crate::state::AppState; +use crate::state::{AppState, IdleReembedStatus}; + +const NO_PROGRESS_BACKOFF: Duration = Duration::from_secs(60 * 60); + +/// RAII guard that clears the `reembed_running` flag and the `active` UI bit +/// when dropped — even if the closure that owns it panics outside +/// `catch_unwind`, or the runtime cancels the task. This is what guarantees +/// the loop can't get permanently stuck thinking a run is in flight. +struct RunGuard { + flag: Arc, + state: Arc>, +} + +impl Drop for RunGuard { + fn drop(&mut self) { + self.flag.store(false, Ordering::Relaxed); + if let Ok(mut st) = self.state.lock() { + st.active = false; + } + } +} + +/// Sample system memory usage and return (used_percent, used_bytes, total_bytes). +/// Cheap enough to call once per 10s tick. +fn sample_memory_percent() -> (u8, u64, u64) { + let sys = sysinfo::System::new_with_specifics( + sysinfo::RefreshKind::nothing().with_memory(sysinfo::MemoryRefreshKind::everything()), + ); + let used = sys.used_memory(); + let total = sys.total_memory(); + if total == 0 { + return (0, used, total); + } + let pct = ((used as u128 * 100) / total as u128).min(100) as u8; + (pct, used, total) +} + +/// Whether to record a no-progress cooldown after a run finishes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackoffAfterRun { + /// Do not start the 1h cooldown (cancelled or embeddings were written). + Cleared, + /// Same or higher missing count — avoid restarting every 10s. + Set { remaining: i64 }, +} + +fn backoff_after_run(remaining: i64, started_missing: i64, cancelled: bool) -> BackoffAfterRun { + if cancelled { + BackoffAfterRun::Cleared + } else if remaining >= started_missing { + BackoffAfterRun::Set { remaining } + } else { + BackoffAfterRun::Cleared + } +} + +fn should_back_off_no_progress( + no_progress_backoff: &Mutex>, + needed: i64, + backoff_for: Duration, +) -> bool { + no_progress_backoff + .lock() + .map(|mut guard| { + let (active, stale) = match guard.as_ref() { + Some((missing, started_at)) => (*missing == needed && started_at.elapsed() < backoff_for, true), + None => (false, false), + }; + if stale && !active { + *guard = None; + } + active + }) + .unwrap_or(false) +} + +/// Compute remaining backoff seconds (0 if backoff is not active for `needed`). +fn backoff_remaining_secs( + no_progress_backoff: &Mutex>, + needed: i64, + backoff_for: Duration, +) -> u64 { + no_progress_backoff + .lock() + .ok() + .and_then(|guard| guard.as_ref().copied()) + .filter(|(missing, _)| *missing == needed) + .map(|(_, started_at)| backoff_for.saturating_sub(started_at.elapsed()).as_secs()) + .unwrap_or(0) +} /// Spawn the background idle-reembed loop. /// Runs forever, checking device state every 10 seconds. @@ -21,10 +113,17 @@ pub fn spawn_idle_reembed_loop(state: AppState) { tokio::time::sleep(Duration::from_secs(10)).await; let mut last_connected = Instant::now(); - let mut reembed_running = false; + let reembed_running = Arc::new(AtomicBool::new(false)); + let no_progress_backoff: Arc>> = Arc::new(Mutex::new(None)); + let mut last_throttle_log = Instant::now() + .checked_sub(Duration::from_secs(600)) + .unwrap_or_else(Instant::now); loop { tokio::time::sleep(Duration::from_secs(10)).await; + // Heartbeat marks the polling loop tick (not the actual embed run, + // which spawn_blocking does separately and updates `idle_reembed_state`). + state.record_task_heartbeat("idle-reembed", 0); // Load current settings every tick (user may change them). let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); @@ -32,9 +131,8 @@ pub fn spawn_idle_reembed_loop(state: AppState) { let cfg = &settings.reembed; if !cfg.idle_reembed_enabled { - if reembed_running { + if reembed_running.load(Ordering::Relaxed) { state.idle_reembed_cancel.store(true, Ordering::Relaxed); - reembed_running = false; } continue; } @@ -47,10 +145,9 @@ pub fn spawn_idle_reembed_loop(state: AppState) { if is_connected { last_connected = Instant::now(); // Cancel any running background reembed immediately. - if reembed_running { + if reembed_running.load(Ordering::Relaxed) { info!("[idle-reembed] device connected — pausing background reembed"); state.idle_reembed_cancel.store(true, Ordering::Relaxed); - reembed_running = false; } continue; } @@ -62,7 +159,7 @@ pub fn spawn_idle_reembed_loop(state: AppState) { if let Ok(mut st) = state.idle_reembed_state.lock() { st.idle_secs = idle_secs; st.delay_secs = cfg.idle_reembed_delay_secs; - if !reembed_running { + if !reembed_running.load(Ordering::Relaxed) { st.active = false; } } @@ -72,7 +169,7 @@ pub fn spawn_idle_reembed_loop(state: AppState) { } // Check if there's work to do. - if reembed_running { + if reembed_running.load(Ordering::Relaxed) { continue; // Already processing. } @@ -87,10 +184,56 @@ pub fn spawn_idle_reembed_loop(state: AppState) { st.active = false; st.total = 0; st.done = 0; + st.memory_throttled = false; } continue; } + if should_back_off_no_progress(&no_progress_backoff, needed, NO_PROGRESS_BACKOFF) { + let remaining = backoff_remaining_secs(&no_progress_backoff, needed, NO_PROGRESS_BACKOFF); + if let Ok(mut st) = state.idle_reembed_state.lock() { + st.backoff_secs_remaining = remaining; + st.backoff_reason = format!("no embedding progress; {needed} still missing"); + st.active = false; + } + continue; + } + // Clear any stale backoff display once it has expired or cleared. + if let Ok(mut st) = state.idle_reembed_state.lock() { + if st.backoff_secs_remaining != 0 || !st.backoff_reason.is_empty() { + st.backoff_secs_remaining = 0; + st.backoff_reason.clear(); + } + } + + // Memory backpressure: skip the run if system memory is already + // saturated. Embedding (especially with GPU/Metal) can add hundreds + // of MB of resident memory and OOM the user's machine. + let (mem_pct, mem_used, mem_total) = sample_memory_percent(); + let limit = cfg.max_resident_memory_percent.min(100); + if limit < 100 && mem_pct >= limit { + if let Ok(mut st) = state.idle_reembed_state.lock() { + st.memory_throttled = true; + st.memory_percent = mem_pct; + st.active = false; + } + // Rate-limit the warning so we don't spam the log every 10s. + if last_throttle_log.elapsed() >= Duration::from_secs(300) { + warn!( + "[idle-reembed] deferring: system memory {mem_pct}% \ + ({} / {} MiB) >= max_resident_memory_percent={limit}", + mem_used / (1024 * 1024), + mem_total / (1024 * 1024), + ); + last_throttle_log = Instant::now(); + } + continue; + } + if let Ok(mut st) = state.idle_reembed_state.lock() { + st.memory_throttled = false; + st.memory_percent = mem_pct; + } + info!( "[idle-reembed] device idle for {}s, {} epochs need embeddings — starting background reembed", idle_secs, needed @@ -98,7 +241,7 @@ pub fn spawn_idle_reembed_loop(state: AppState) { // Reset cancel flag and start. state.idle_reembed_cancel.store(false, Ordering::Relaxed); - reembed_running = true; + reembed_running.store(true, Ordering::Relaxed); if let Ok(mut st) = state.idle_reembed_state.lock() { st.active = true; @@ -108,32 +251,98 @@ pub fn spawn_idle_reembed_loop(state: AppState) { } let state_clone = state.clone(); + let running_flag = reembed_running.clone(); + let backoff_state = no_progress_backoff.clone(); + let started_missing = needed; + let skill_dir_for_backoff = skill_dir.clone(); + let cancel_for_backoff = state.idle_reembed_cancel.clone(); let use_gpu = cfg.idle_reembed_gpu; let throttle_ms = cfg.idle_reembed_throttle_ms; let batch_size = cfg.batch_size.max(1); - tokio::task::spawn_blocking(move || { - if let Err(e) = run_idle_reembed(&state_clone, use_gpu, throttle_ms, batch_size) { - warn!("[idle-reembed] failed: {e}"); - } - // Rebuild label EEG index so interactive search picks up new embeddings. - let skill_dir = state_clone.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let stats = skill_label_index::rebuild(&skill_dir, &state_clone.label_index); - info!( - "[idle-reembed] label index rebuilt: {} text, {} eeg ({} skipped)", - stats.text_nodes, stats.eeg_nodes, stats.eeg_skipped - ); - // Mark idle reembed as done. - if let Ok(mut st) = state_clone.idle_reembed_state.lock() { - st.active = false; - } - // Signal completion. + let state_for_guard = state.idle_reembed_state.clone(); + let running_for_guard = running_flag.clone(); + let handle = tokio::task::spawn_blocking(move || { + // RAII guard: even if the closure panics *outside* catch_unwind + // (or the runtime drops the task), the flag and UI active bit + // are restored. Watcher failures can no longer orphan us. + let _guard = RunGuard { + flag: running_for_guard, + state: state_for_guard, + }; + + // Wrap the reembed body in catch_unwind so a panic in encoder + // load, encode, or label-index rebuild surfaces as a logged + // join error rather than a silently orphaned task. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Err(e) = run_idle_reembed(&state_clone, use_gpu, throttle_ms, batch_size) { + warn!("[idle-reembed] failed: {e}"); + } + // Rebuild label EEG index so interactive search picks up new embeddings. + let skill_dir = state_clone.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); + let stats = skill_label_index::rebuild(&skill_dir, &state_clone.label_index); + info!( + "[idle-reembed] label index rebuilt: {} text, {} eeg ({} skipped)", + stats.text_nodes, stats.eeg_nodes, stats.eeg_skipped + ); + })); + + let panicked = result.is_err(); + let status = if panicked { "idle_panic" } else { "idle_done" }; let _ = state_clone.events_tx.send(skill_daemon_common::EventEnvelope { r#type: "reembed-progress".into(), ts_unix_ms: now_unix_ms(), correlation_id: None, - payload: serde_json::json!({ "status": "idle_done" }), + payload: serde_json::json!({ "status": status }), }); + + if panicked { + warn!("[idle-reembed] worker panicked — task body unwound, state cleared"); + } + // _guard drops here, clearing reembed_running and active. + }); + + // Watcher: compute backoff after the work task finishes. The + // work task's RunGuard owns the flag-clear, so a panic in this + // watcher (or a runtime drop) can no longer wedge the loop. + let running_for_watcher = reembed_running.clone(); + tokio::spawn(async move { + let join_result = handle.await; + if let Err(join_err) = &join_result { + if join_err.is_panic() { + warn!("[idle-reembed] task panicked outside catch_unwind: {join_err}"); + } else if join_err.is_cancelled() { + info!("[idle-reembed] task cancelled"); + } + } + let cancelled = cancel_for_backoff.load(Ordering::Relaxed); + let remaining = if cancelled { + started_missing + } else { + tokio::task::spawn_blocking(move || count_missing_embeddings(&skill_dir_for_backoff)) + .await + .unwrap_or(started_missing) + }; + match backoff_after_run(remaining, started_missing, cancelled) { + BackoffAfterRun::Cleared => { + if let Ok(mut guard) = backoff_state.lock() { + *guard = None; + } + } + BackoffAfterRun::Set { remaining } => { + if let Ok(mut guard) = backoff_state.lock() { + *guard = Some((remaining, Instant::now())); + } + warn!( + "[idle-reembed] made no embedding progress ({remaining} still missing); backing off for {} minutes", + NO_PROGRESS_BACKOFF.as_secs() / 60 + ); + } + } + // Belt-and-suspenders: if the work task ran to completion the + // RunGuard already cleared this. If it was dropped without + // running, clear it here so the next tick can start work. + running_for_watcher.store(false, Ordering::Relaxed); }); } }); @@ -185,9 +394,15 @@ fn run_idle_reembed(state: &AppState, use_gpu: bool, throttle_ms: u64, batch_siz // Subscribe to progress events so we can mirror them into the observable state. let mut rx = state.events_tx.subscribe(); - // Spawn a helper thread to update idle_reembed_state from progress events. + // Spawn a helper thread to update idle_reembed_state from progress events + // *and* record a real heartbeat for each batch — so the activity panel + // shows actual embed throughput (e.g. "took 240 ms · 1234 ticks") rather + // than the 0-ms ticks of the outer 10s polling loop. let idle_state_clone = idle_state.clone(); + let state_for_hb = state.clone(); let updater = std::thread::spawn(move || { + let mut prev_done: u64 = 0; + let mut prev_progress_at = std::time::Instant::now(); while let Ok(ev) = rx.blocking_recv() { if ev.r#type != "reembed-progress" { continue; @@ -205,7 +420,21 @@ fn run_idle_reembed(state: &AppState, use_gpu: bool, throttle_ms: u64, batch_siz st.current_day = day; } } - if matches!(status, "done" | "idle_done" | "complete" | "paused") { + + // If `done` advanced, that batch finished work — record a heartbeat + // with the elapsed wall-clock for that batch. We use saturating + // arithmetic in case events arrive out of order. + if done > prev_done { + let elapsed_ms = prev_progress_at.elapsed().as_millis() as u64; + state_for_hb.record_task_heartbeat("idle-reembed", elapsed_ms); + prev_done = done; + prev_progress_at = std::time::Instant::now(); + } + + if matches!( + status, + "done" | "idle_done" | "complete" | "paused" | "error" | "idle_panic" + ) { break; } } @@ -221,6 +450,82 @@ fn run_idle_reembed(state: &AppState, use_gpu: bool, throttle_ms: u64, batch_siz batch_size, ); + if let Err(e) = &result { + let _ = state.events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "reembed-progress".into(), + ts_unix_ms: now_unix_ms(), + correlation_id: None, + payload: serde_json::json!({ "status": "error", "message": e.to_string() }), + }); + } + let _ = updater.join(); result } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_reembed_no_backoff_when_state_unset() { + let backoff = Mutex::new(None); + assert!(!should_back_off_no_progress(&backoff, 42, Duration::from_secs(60 * 60))); + } + + #[test] + fn idle_reembed_backoff_active_for_same_missing_count() { + let backoff = Mutex::new(Some((42, Instant::now()))); + + assert!(should_back_off_no_progress(&backoff, 42, Duration::from_secs(60 * 60))); + assert!(backoff.lock().unwrap().is_some()); + } + + #[test] + fn idle_reembed_backoff_clears_when_missing_count_changes() { + let backoff = Mutex::new(Some((42, Instant::now()))); + + assert!(!should_back_off_no_progress(&backoff, 41, Duration::from_secs(60 * 60))); + assert!(backoff.lock().unwrap().is_none()); + } + + #[test] + fn idle_reembed_backoff_clears_after_cooldown() { + let backoff = Mutex::new(Some(( + 42, + Instant::now() + .checked_sub(Duration::from_secs(60 * 61)) + .expect("test duration is within Instant range"), + ))); + + assert!(!should_back_off_no_progress(&backoff, 42, Duration::from_secs(60 * 60))); + assert!(backoff.lock().unwrap().is_none()); + } + + #[test] + fn idle_reembed_backoff_after_run_when_no_progress() { + assert_eq!( + backoff_after_run(100, 100, false), + BackoffAfterRun::Set { remaining: 100 } + ); + } + + #[test] + fn idle_reembed_backoff_after_run_when_count_increased() { + assert_eq!( + backoff_after_run(110, 100, false), + BackoffAfterRun::Set { remaining: 110 } + ); + } + + #[test] + fn idle_reembed_backoff_after_run_cleared_on_partial_progress() { + assert_eq!(backoff_after_run(80, 100, false), BackoffAfterRun::Cleared); + } + + #[test] + fn idle_reembed_backoff_after_run_cleared_when_cancelled() { + assert_eq!(backoff_after_run(100, 100, true), BackoffAfterRun::Cleared); + assert_eq!(backoff_after_run(110, 100, true), BackoffAfterRun::Cleared); + } +} diff --git a/crates/skill-daemon/src/main.rs b/crates/skill-daemon/src/main.rs index 35389ad2..db3f2cbc 100644 --- a/crates/skill-daemon/src/main.rs +++ b/crates/skill-daemon/src/main.rs @@ -21,7 +21,6 @@ pub(crate) mod session_runner; mod tty; #[cfg(unix)] mod tty_backfill; -#[cfg(unix)] mod tty_embedder; #[cfg(unix)] mod tty_finalizer; @@ -53,28 +52,105 @@ fn main() -> anyhow::Result<()> { let args: Vec = std::env::args().collect(); #[cfg(unix)] if args.get(1).map(String::as_str) == Some("tty") { - return tty::run(&args[2..]); + // Back-compat shim: shell rc files generated by older builds invoke + // `skill-daemon tty`. The PTY proxy now lives in its own binary + // (`skill-tty`) so blanket process-name kills (Tauri sidecar reload, + // kill-old-daemon-on-upgrade) no longer terminate active recorded + // shells. If we can find the sibling binary, exec into it; otherwise + // fall back to the in-process implementation so the user's terminal + // does not break during upgrade. + tty_shim_dispatch(&args[2..]); } daemon_main() } +/// Try to exec the sibling `skill-tty` binary, otherwise run the legacy +/// in-process PTY proxy. Either path calls `std::process::exit` and never +/// returns to the caller. +#[cfg(unix)] +fn tty_shim_dispatch(forward_args: &[String]) -> ! { + use std::ffi::CString; + + if let Some(sibling) = util::resolve_skill_tty_path() { + let argv0 = match CString::new(sibling.as_os_str().as_encoded_bytes()) { + Ok(s) => s, + Err(_) => fall_back(forward_args), + }; + let mut owned: Vec = Vec::with_capacity(forward_args.len() + 1); + owned.push(argv0); + for a in forward_args { + match CString::new(a.as_str()) { + Ok(s) => owned.push(s), + Err(_) => fall_back(forward_args), + } + } + let mut argv: Vec<*const libc::c_char> = owned.iter().map(|s| s.as_ptr()).collect(); + argv.push(std::ptr::null()); + // execv replaces this process; only returns on failure. + // SAFETY: `owned[0]` is a valid NUL-terminated CString and `argv` is a + // NULL-terminated array of pointers to valid CStrings, all kept alive for + // the duration of this call. + unsafe { libc::execv(owned[0].as_ptr(), argv.as_ptr()) }; + eprintln!( + "skill-daemon tty: execv({}) failed: {} — falling back to in-process shim", + sibling.display(), + std::io::Error::last_os_error() + ); + } + fall_back(forward_args) +} + +#[cfg(unix)] +fn fall_back(forward_args: &[String]) -> ! { + if let Err(e) = tty::run(forward_args) { + eprintln!("skill-daemon tty: {e:#}"); + std::process::exit(126); + } + // tty::run already calls process::exit on success, but be explicit. + std::process::exit(0); +} + #[tokio::main] async fn daemon_main() -> anyhow::Result<()> { - // Handle --uninstall flag: remove the OS service and exit immediately. + // Handle --uninstall flag: remove the OS service AND clean up every + // shell hook we ever wrote into the user's rc files. Removing only the + // service used to leave stale `source ~/.skill/shell-hooks/...` lines in + // ~/.zshrc etc. which then errored on every new terminal after the + // binary was gone — exactly the kind of "modified my dotfiles and + // didn't put them back" the user gets to be angry about. if std::env::args().any(|a| a == "--uninstall") { let binary_path = std::env::current_exe().unwrap_or_default(); let installer = service_installer::ServiceInstaller::new(binary_path); installer.uninstall()?; println!("Service uninstalled successfully."); + + let skill_dir = skill_data_dir(); + let report = routes::settings::uninstall_all_shell_hooks(&skill_dir); + for (shell, info) in &report { + if info.is_empty() { + continue; + } + if info.starts_with("error: ") { + eprintln!("Shell hook cleanup [{shell}]: {info}"); + } else { + println!("Cleaned shell hook for {shell} (rc: {info})"); + } + } return Ok(()); } - // Write PID file for process management - let pid_path = dirs::config_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("skill") - .join("daemon") - .join("daemon.pid"); + // Write PID file for process management. + // SKILL_DAEMON_CONFIG_ROOT overrides the location for tests / sandboxes + // (mirror of the same hook in src-tauri/src/daemon_upgrade.rs). + let pid_dir = if let Ok(p) = std::env::var("SKILL_DAEMON_CONFIG_ROOT") { + std::path::PathBuf::from(p) + } else { + dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("skill") + .join("daemon") + }; + let pid_path = pid_dir.join("daemon.pid"); if let Some(parent) = pid_path.parent() { let _ = std::fs::create_dir_all(parent); } @@ -150,7 +226,6 @@ async fn daemon_main() -> anyhow::Result<()> { tty_finalizer::spawn(state.clone()); // Fill in `terminal_outputs.embedding` for finalized rows. Runs every // 30 s, batches of 32, int8-quantised vectors. - #[cfg(unix)] tty_embedder::spawn(state.clone()); // Auto-refresh installed shell hooks so upgrades propagate fixes (e.g. the @@ -283,6 +358,7 @@ async fn daemon_main() -> anyhow::Result<()> { .merge(routes::search::router()) .merge(routes::iroh::router()) .merge(routes::brain::router()) + .merge(routes::activity_status::router()) .merge(routes::validation::router()); // Test-mode endpoints — debug builds only @@ -853,4 +929,159 @@ mod tests { let _ = shutdown_tx.send(()); let _ = handle.await; } + + /// Regression: command dispatch must stay responsive while broadcast + /// events are flooding the connection. + /// + /// Before the priority-queue + subscribe-gating fix, ~300–500 high-rate + /// events/sec on a single WS connection would fill the kernel TCP send + /// buffer, block `sender.send(event).await`, and starve every command + /// response — the smoke test saw 15s per-command timeouts. + /// + /// This test subscribes to the high-rate stream, broadcasts 1000 + /// EegSample envelopes, and asserts the response to `{command:"status"}` + /// arrives within 1500ms. The pre-fix daemon would take 5+s. + #[tokio::test] + async fn ws_command_responds_under_event_flood() { + use tokio_tungstenite::tungstenite::Message; + + let td = TempDir::new().unwrap(); + let state = AppState::new("flood-token".to_string(), td.path().to_path_buf()); + let app = test_app(state.clone()); + let (addr, shutdown_tx, handle) = spawn_test_server(app).await; + + let url = format!("ws://{addr}/v1/events?token=flood-token"); + let (mut ws, _resp) = connect_async(url).await.expect("ws connect"); + + // 1) drain welcome envelope + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ws.next()) + .await + .expect("welcome timeout") + .expect("ws closed") + .expect("ws error"); + + // 2) opt in to the high-rate stream + ws.send(Message::Text(r#"{"command":"subscribe","events":["*"]}"#.into())) + .await + .expect("send subscribe"); + + // 3) drain the subscribe ack + for _ in 0..4 { + let msg = tokio::time::timeout(std::time::Duration::from_secs(2), ws.next()) + .await + .expect("ack timeout") + .expect("ws closed") + .expect("ws error"); + if let Message::Text(t) = msg { + let v: serde_json::Value = serde_json::from_str(&t).unwrap(); + if v["command"] == "subscribe" { + break; + } + } + } + + // 4) flood the broadcast channel with high-rate envelopes + for i in 0..1000 { + let _ = state.events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "EegSample".to_string(), + ts_unix_ms: i as u64, + correlation_id: None, + payload: serde_json::json!({"channels": [0.0, 0.0, 0.0, 0.0], "timestamp": i as f64}), + }); + } + + // 5) send a command and time its response + let t0 = std::time::Instant::now(); + ws.send(Message::Text(r#"{"command":"status"}"#.into())) + .await + .expect("send status"); + + let mut saw_status = false; + let mut elapsed = std::time::Duration::ZERO; + // Drain up to ~2000 frames waiting for the response — most will + // be EegSample echoes that the sender task hasn't drained yet. + for _ in 0..2500 { + let msg = tokio::time::timeout(std::time::Duration::from_secs(3), ws.next()) + .await + .expect("response timeout") + .expect("ws closed") + .expect("ws error"); + if let Message::Text(t) = msg { + let v: serde_json::Value = serde_json::from_str(&t).unwrap(); + if v["command"] == "status" { + saw_status = true; + elapsed = t0.elapsed(); + break; + } + } + } + assert!(saw_status, "status response never arrived under event flood"); + assert!( + elapsed < std::time::Duration::from_millis(1500), + "status response took {elapsed:?} under event flood — \ + priority-queue regression (pre-fix took 5+s)" + ); + + let _ = ws.close(None).await; + let _ = shutdown_tx.send(()); + let _ = handle.await; + } + + /// Regression: high-rate events MUST be filtered out when the client + /// hasn't subscribed. Otherwise dashboards that don't opt in still get + /// the firehose and the daemon's TCP send buffer fills. + #[tokio::test] + async fn ws_filters_high_rate_events_by_default() { + use tokio_tungstenite::tungstenite::Message; + + let td = TempDir::new().unwrap(); + let state = AppState::new("filter-token".to_string(), td.path().to_path_buf()); + let app = test_app(state.clone()); + let (addr, shutdown_tx, handle) = spawn_test_server(app).await; + + let url = format!("ws://{addr}/v1/events?token=filter-token"); + let (mut ws, _resp) = connect_async(url).await.expect("ws connect"); + + // welcome + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ws.next()) + .await + .expect("welcome timeout") + .expect("ws closed") + .expect("ws error"); + + // Fire one high-rate and one low-rate event. + let _ = state.events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "EegSample".to_string(), + ts_unix_ms: 1, + correlation_id: None, + payload: serde_json::json!({"x": 1}), + }); + let _ = state.events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "StatusUpdate".to_string(), + ts_unix_ms: 2, + correlation_id: None, + payload: serde_json::json!({"x": 2}), + }); + + let mut saw_eeg = false; + let mut saw_status = false; + for _ in 0..8 { + let res = tokio::time::timeout(std::time::Duration::from_millis(500), ws.next()).await; + let Ok(Some(Ok(Message::Text(t)))) = res else { + break; + }; + let v: serde_json::Value = serde_json::from_str(&t).unwrap(); + match v["type"].as_str() { + Some("EegSample") => saw_eeg = true, + Some("StatusUpdate") => saw_status = true, + _ => {} + } + } + assert!(saw_status, "low-rate event must be forwarded"); + assert!(!saw_eeg, "high-rate event must be filtered until client subscribes"); + + let _ = ws.close(None).await; + let _ = shutdown_tx.send(()); + let _ = handle.await; + } } diff --git a/crates/skill-daemon/src/monitor.rs b/crates/skill-daemon/src/monitor.rs index cd65a6b3..92f68626 100644 --- a/crates/skill-daemon/src/monitor.rs +++ b/crates/skill-daemon/src/monitor.rs @@ -55,6 +55,7 @@ pub fn spawn_status_monitor(state: AppState) { loop { tokio::time::sleep(Duration::from_secs(3)).await; + state.record_task_heartbeat("status-monitor", 0); let status = match state.status.lock() { Ok(s) => s.clone(), diff --git a/crates/skill-daemon/src/reconnect.rs b/crates/skill-daemon/src/reconnect.rs index 75e64eab..8705a89f 100644 --- a/crates/skill-daemon/src/reconnect.rs +++ b/crates/skill-daemon/src/reconnect.rs @@ -95,6 +95,7 @@ pub fn spawn_reconnect_loop(state: AppState, reconnect: Arc50ms). + pub cost: &'static str, + /// Whether the user can disable this from settings. + pub user_toggleable: bool, + /// Live heartbeat from the central registry (zeroed if the loop has + /// not ticked yet). + pub heartbeat: Heartbeat, + /// Live state, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct Heartbeat { + pub last_tick_unix_ms: u64, + pub last_duration_ms: u64, + pub tick_count: u64, +} + +#[derive(Debug, Serialize)] +pub struct TaskState { + pub running: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Serialize)] +pub struct ActivityResponse { + pub tasks: Vec, +} + +/// Static metadata for every background task. Adding a new entry here is +/// what makes a new worker visible in the panel — pair this with a +/// `state.record_task_heartbeat(id, ms)` call inside the worker's loop. +struct StaticEntry { + id: &'static str, + name: &'static str, + does: &'static str, + why: &'static str, + interval_secs: u64, + cost: &'static str, + user_toggleable: bool, +} + +const MANIFEST: &[StaticEntry] = &[ + StaticEntry { + id: "device-scanner", + name: "Device scanner", + does: "Probes USB serial ports, BLE adapters, Cortex, NeuroField, BrainBit, g.tec, ANT Neuro, BrainMaster.", + why: "So when you plug in or power on a device, it shows up automatically without you opening a menu.", + interval_secs: 5, + cost: "medium", + user_toggleable: true, + }, + StaticEntry { + id: "status-monitor", + name: "Device status monitor", + does: "Reads device battery and signal quality; warns at low battery / poor signal.", + why: "Required to keep the connection indicator live and to flash a toast before a recording dies.", + interval_secs: 3, + cost: "low", + user_toggleable: false, + }, + StaticEntry { + id: "idle-reembed", + name: "Idle re-embedding", + does: "When the device has been idle for 30 min, embeds older epochs in the background.", + why: "Keeps embedding search current after a model upgrade. Pauses immediately when a device reconnects.", + interval_secs: 10, + cost: "high", + user_toggleable: true, + }, + StaticEntry { + id: "active-window-poll", + name: "Active window tracker", + does: "Records which app/window is in focus and detects file/build/meeting changes.", + why: "Powers the activity timeline and focus-session reports. Off by default.", + interval_secs: 3, + cost: "low", + user_toggleable: true, + }, + StaticEntry { + id: "input-monitor", + name: "Input activity monitor", + does: "Detects keyboard / mouse activity to mark you as 'active'.", + why: "Distinguishes idle time from real work for focus reports.", + interval_secs: 0, + cost: "low", + user_toggleable: true, + }, + StaticEntry { + id: "clipboard-monitor", + name: "Clipboard monitor (macOS)", + does: "Watches NSPasteboard.changeCount and records copy events. Captures clipboard images when enabled.", + why: "Lets you find 'that thing I copied an hour ago'. The native change-count check is ~free when you aren't copying.", + interval_secs: 2, + cost: "low", + user_toggleable: true, + }, + StaticEntry { + id: "tty-embedder", + name: "Terminal output embedder", + does: "Embeds finalized terminal session text so it can be searched.", + why: "Powers terminal search. Runs in batches of 32 every 30s.", + interval_secs: 30, + cost: "medium", + user_toggleable: true, + }, + StaticEntry { + id: "reconnect", + name: "Reconnect state machine", + does: "Counts down a retry timer when a device disconnects unexpectedly.", + why: "Required to auto-reconnect after a brief BLE/USB hiccup.", + interval_secs: 1, + cost: "low", + user_toggleable: false, + }, + StaticEntry { + id: "skills-sync", + name: "Skills sync", + does: "Pulls remote skill manifest updates.", + why: "Keeps the skill catalog current.", + interval_secs: 0, + cost: "low", + user_toggleable: true, + }, +]; + +async fn get_activity(State(state): State) -> Json { + let scanner_running = state.scanner_running.lock().map(|g| *g).unwrap_or(false); + let (idle_active, idle_detail) = match state.idle_reembed_state.lock() { + Ok(s) => { + let detail = if s.active { + Some(format!("processing {}/{} epochs", s.done, s.total)) + } else if s.delay_secs > 0 { + Some(format!( + "waiting — idle for {}s of {}s before starting", + s.idle_secs, s.delay_secs + )) + } else { + None + }; + (s.active, detail) + } + Err(_) => (false, None), + }; + + let heartbeats = state.task_heartbeats.lock().ok().map(|m| m.clone()).unwrap_or_default(); + + let mut tasks = Vec::with_capacity(MANIFEST.len()); + for entry in MANIFEST { + let hb = heartbeats.get(entry.id).cloned().unwrap_or_default(); + let live_state = match entry.id { + "device-scanner" => Some(TaskState { + running: scanner_running, + detail: Some("backs off to every 30s after 5 minutes of no devices and none paired".into()), + }), + "idle-reembed" => Some(TaskState { + running: idle_active, + detail: idle_detail.clone(), + }), + "active-window-poll" => Some(TaskState { + running: state.track_active_window.load(std::sync::atomic::Ordering::Relaxed), + detail: None, + }), + "input-monitor" => Some(TaskState { + running: state.track_input_activity.load(std::sync::atomic::Ordering::Relaxed), + detail: Some("event-driven via the OS input stack".into()), + }), + "skills-sync" => Some(TaskState { + running: false, + detail: Some("interval set in settings (skills_refresh_interval_secs)".into()), + }), + _ => None, + }; + tasks.push(BackgroundTask { + id: entry.id, + name: entry.name, + does: entry.does, + why: entry.why, + interval_secs: entry.interval_secs, + cost: entry.cost, + user_toggleable: entry.user_toggleable, + heartbeat: Heartbeat { + last_tick_unix_ms: hb.last_tick_unix_ms, + last_duration_ms: hb.last_duration_ms, + tick_count: hb.tick_count, + }, + state: live_state, + }); + } + + Json(ActivityResponse { tasks }) +} + +pub fn router() -> Router { + Router::new().route("/activity", get(get_activity)) +} diff --git a/crates/skill-daemon/src/routes/labels.rs b/crates/skill-daemon/src/routes/labels.rs index c19b15d9..107f25fb 100644 --- a/crates/skill-daemon/src/routes/labels.rs +++ b/crates/skill-daemon/src/routes/labels.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use skill_constants::LABELS_FILE; use skill_daemon_common::ApiError; +use crate::routes::settings_io::patch_user_settings_sync; use crate::state::AppState; // ── Types ───────────────────────────────────────────────────────────────────── @@ -64,6 +65,11 @@ pub struct SearchByEegRequest { pub k: Option, } +#[derive(Debug, Deserialize)] +pub struct SetLabelIndexBackendRequest { + pub backend: String, +} + // ── Router ──────────────────────────────────────────────────────────────────── pub fn router() -> Router { @@ -73,6 +79,11 @@ pub fn router() -> Router { .route("/labels/search", post(search_labels)) .route("/labels/search-by-eeg", post(search_labels_by_eeg)) .route("/labels/index/rebuild", post(rebuild_label_index)) + .route( + "/labels/index/backend", + get(get_label_index_backend).post(set_label_index_backend), + ) + .route("/labels/index/benchmark", post(benchmark_label_index)) .route("/labels/index/stats", get(label_index_stats)) .route("/labels/embedding-status", get(label_embedding_status)) .route("/labels/reembed", post(reembed_all_labels)) @@ -585,31 +596,89 @@ async fn rebuild_label_index(State(state): State) -> Json) -> Json { + let backend = state.label_index.preferred_backend(); + Json(serde_json::json!({ + "backend": backend.as_str(), + "available": ["hnsw", "turboquant"], + "aliases": { "turboquant": ["turbovec", "turbo_vec", "tv"] }, + })) +} + +async fn set_label_index_backend( + State(state): State, + Json(req): Json, +) -> Json { + let Some(backend) = skill_label_index::LabelIndexBackend::parse(&req.backend) else { + return Json(serde_json::json!({ + "ok": false, + "error": "backend must be 'hnsw' or 'turboquant'", + })); + }; + + state.label_index.set_preferred_backend(backend); + let backend_name = backend.as_str().to_string(); + patch_user_settings_sync(&state, move |s| { + s.label_index_backend = backend_name; + }); + + Json(serde_json::json!({ + "ok": true, + "backend": backend.as_str(), + })) +} + +async fn benchmark_label_index( + State(state): State, + Json(req): Json, +) -> Json { + let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); + let label_index = state.label_index.clone(); + let embedder = state.text_embedder.clone(); + let k = req.k.unwrap_or(10); + let ef = req.ef.unwrap_or(64); + let mode = req.mode.clone().unwrap_or_else(|| "text".into()); + let query_text = req.query.clone(); + + let result = tokio::task::spawn_blocking(move || { + let Some(query_vec) = embedder.embed(&query_text) else { + return serde_json::json!({ "ok": false, "error": "failed to embed query" }); + }; + let benchmarks = match mode.as_str() { + "context" => skill_label_index::benchmark_context_vec(&query_vec, k, ef, &skill_dir, &label_index), + "eeg" => skill_label_index::benchmark_eeg_vec(&query_vec, k, ef, &skill_dir, &label_index), + _ => skill_label_index::benchmark_text_vec(&query_vec, k, ef, &skill_dir, &label_index), + }; + let comparison = skill_label_index::compare_benchmarks(&benchmarks); + serde_json::json!({ + "ok": true, + "mode": mode, + "preferred_backend": label_index.preferred_backend().as_str(), + "benchmarks": benchmarks, + "comparison": comparison, + }) + }) + .await + .unwrap_or_else(|e| serde_json::json!({ "ok": false, "error": e.to_string() })); + + Json(result) +} + /// Stats about the current in-memory label indices. async fn label_index_stats(State(state): State) -> Json { let label_index = state.label_index.clone(); - let text_len = label_index - .text - .lock() - .ok() - .and_then(|g| g.as_ref().map(|i| i.len())) - .unwrap_or(0); - let context_len = label_index - .context - .lock() - .ok() - .and_then(|g| g.as_ref().map(|i| i.len())) - .unwrap_or(0); - let eeg_len = label_index - .eeg - .lock() - .ok() - .and_then(|g| g.as_ref().map(|i| i.len())) - .unwrap_or(0); + let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); + let hnsw = label_index.hnsw_counts(); + let turbovec = label_index.turbovec_counts(); + let memory = label_index.memory_footprint(&skill_dir); Json(serde_json::json!({ - "text_nodes": text_len, - "context_nodes": context_len, - "eeg_nodes": eeg_len, + "preferred_backend": label_index.preferred_backend().as_str(), + "text_nodes": hnsw.text_nodes, + "context_nodes": hnsw.context_nodes, + "eeg_nodes": hnsw.eeg_nodes, + "hnsw": hnsw, + "turbovec": turbovec, + "memory": memory, })) } diff --git a/crates/skill-daemon/src/routes/mod.rs b/crates/skill-daemon/src/routes/mod.rs index 1296df62..9f957354 100644 --- a/crates/skill-daemon/src/routes/mod.rs +++ b/crates/skill-daemon/src/routes/mod.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only +pub mod activity_status; pub mod analysis; pub mod api; pub mod brain; @@ -10,6 +11,7 @@ pub mod settings; pub(crate) mod settings_calibration; pub(crate) mod settings_device; pub(crate) mod settings_exg; +pub(crate) mod settings_handlers; pub(crate) mod settings_hooks_activity; pub(crate) mod settings_io; pub(crate) mod settings_llm; diff --git a/crates/skill-daemon/src/routes/settings.rs b/crates/skill-daemon/src/routes/settings.rs index fa35c7cb..45805794 100644 --- a/crates/skill-daemon/src/routes/settings.rs +++ b/crates/skill-daemon/src/routes/settings.rs @@ -20,7 +20,7 @@ use skill_eeg::eeg_model_config::{EegModelStatus, ExgModelConfig}; use crate::{ routes::{ settings_device, settings_exg, settings_hooks_activity, - settings_io::{load_user_settings, save_user_settings}, + settings_io::{load_user_settings, patch_settings, patch_user_settings_sync}, settings_llm::{ get_exg_inference_device, get_hf_endpoint, get_inference_device, get_llm_config, set_exg_inference_device, set_hf_endpoint, set_inference_device, set_llm_config, @@ -145,6 +145,11 @@ pub(crate) struct StringValueRequest { pub(crate) value: String, } +#[derive(Debug, Deserialize)] +pub(crate) struct U64ValueRequest { + pub(crate) value: u64, +} + #[derive(Debug, Deserialize)] pub(crate) struct FilenameRequest { pub(crate) filename: String, @@ -602,19 +607,11 @@ pub fn probe_weights_for_config(config: &ExgModelConfig) -> Option<(String, Stri settings_exg::probe_weights_for_config(config) } -async fn get_reembed_config(state: State) -> Json { - Json(load_user_settings(&state).reembed) -} - -async fn set_reembed_config( - state: State, - Json(config): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.reembed = config; - save_user_settings(&state, &settings); - Json(serde_json::json!({ "ok": true })) -} +crate::settings_struct!( + skill_settings::ReembedConfig, + get_reembed_config, + set_reembed_config => reembed +); #[derive(Debug, serde::Serialize, serde::Deserialize)] struct DaemonWatchdogConfig { @@ -634,10 +631,13 @@ async fn set_daemon_watchdog( state: State, Json(config): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.daemon_auto_restart = config.enabled; - settings.daemon_restart_timeout_secs = config.timeout_secs; - save_user_settings(&state, &settings); + let enabled = config.enabled; + let timeout_secs = config.timeout_secs; + patch_settings(&state, move |s| { + s.daemon_auto_restart = enabled; + s.daemon_restart_timeout_secs = timeout_secs; + }) + .await; Json(serde_json::json!({ "ok": true })) } @@ -753,7 +753,12 @@ async fn get_exg_catalog(state: State) -> Json { async fn get_text_embedding_model(State(state): State) -> Json { let code = state.text_embedder.model_code(); - Json(serde_json::json!({ "model": code })) + Json(serde_json::json!({ + "model": code, + "backend": state.text_embedder.backend().as_str(), + "rlx_device": state.text_embedder.rlx_device(), + "rlx_max_seq": state.text_embedder.rlx_max_seq(), + })) } async fn set_text_embedding_model( @@ -764,22 +769,66 @@ async fn set_text_embedding_model( return Json(serde_json::json!({ "ok": false, "error": "missing 'model' field" })); }; let code = code.to_string(); + let backend = match body.get("backend").and_then(|v| v.as_str()) { + Some(raw) => match crate::text_embedder::TextEmbeddingBackend::parse(raw) { + Some(backend) => Some(backend), + None => { + return Json(serde_json::json!({ + "ok": false, + "error": "backend must be 'fastembed' or 'rlx'" + })); + } + }, + None => None, + }; + let rlx_device = body + .get("rlx_device") + .or_else(|| body.get("rlxDevice")) + .and_then(|v| v.as_str()) + .map(str::to_string); + let rlx_max_seq = body + .get("rlx_max_seq") + .or_else(|| body.get("rlxMaxSeq")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize); let embedder = state.text_embedder.clone(); let state_clone = state.clone(); let result = tokio::task::spawn_blocking(move || { embedder.set_model_code(&code); + if let Some(backend) = backend { + embedder.set_backend(backend); + } + if let Some(device) = &rlx_device { + embedder.set_rlx_device(device); + } + if let Some(max_seq) = rlx_max_seq { + embedder.set_rlx_max_seq(max_seq); + } let ok = embedder.reload(); if ok { - let mut settings = load_user_settings(&state_clone); - settings.text_embedding_model = code.clone(); - save_user_settings(&state_clone, &settings); + let backend = embedder.backend().as_str().to_string(); + let rlx_device = embedder.rlx_device(); + let rlx_max_seq = embedder.rlx_max_seq(); + let model = code.clone(); + patch_user_settings_sync(&state_clone, move |s| { + s.text_embedding_model = model; + s.text_embedding_backend = backend; + s.text_embedding_rlx_device = rlx_device; + s.text_embedding_rlx_max_seq = rlx_max_seq; + }); } (ok, code) }) .await; match result { - Ok((true, code)) => Json(serde_json::json!({ "ok": true, "model": code })), + Ok((true, code)) => Json(serde_json::json!({ + "ok": true, + "model": code, + "backend": state.text_embedder.backend().as_str(), + "rlx_device": state.text_embedder.rlx_device(), + "rlx_max_seq": state.text_embedder.rlx_max_seq(), + })), Ok((false, code)) => { Json(serde_json::json!({ "ok": false, "error": format!("failed to load model '{code}'") })) } @@ -1187,46 +1236,69 @@ async fn uninstall_shell_hook( ) -> Json { let shell: String = body.get("shell").and_then(|v| v.as_str()).unwrap_or("zsh").to_string(); let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let result = tokio::task::spawn_blocking(move || { - let shell = shell.as_str(); - let hook_dir = skill_dir.join("shell-hooks"); - let (ext, rc_path) = shell_rc_info(shell); - let hook_file = hook_dir.join(format!("neuroskill.{ext}")); - - // Delete the hook script - let _ = std::fs::remove_file(&hook_file); - - // Remove the source line from the rc file - if let Some(ref rc) = rc_path { - if let Ok(content) = std::fs::read_to_string(rc) { - let cleaned: String = content - .lines() - .filter(|line| !line.contains("neuroskill")) - .collect::>() - .join("\n"); - // Preserve trailing newline - let cleaned = if content.ends_with('\n') && !cleaned.ends_with('\n') { - cleaned + "\n" - } else { - cleaned - }; - if let Err(e) = std::fs::write(rc, &cleaned) { - return serde_json::json!({"ok": false, "error": format!("failed to update {}: {e}", rc.display())}); - } - } - } - - serde_json::json!({ + let result = tokio::task::spawn_blocking(move || match uninstall_one_shell_hook(&skill_dir, &shell) { + Ok(rc) => serde_json::json!({ "ok": true, "removed": true, - "rc_file": rc_path.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(), - }) + "rc_file": rc.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(), + }), + Err(e) => serde_json::json!({"ok": false, "error": e}), }) .await .unwrap_or_else(|e| serde_json::json!({"ok": false, "error": e.to_string()})); Json(result) } +/// Remove a single shell's hook (delete the generated script and strip the +/// source line from the rc file). Used by both the HTTP uninstall route and +/// the daemon-level `--uninstall` cleanup so the same logic stays in sync. +pub(crate) fn uninstall_one_shell_hook( + skill_dir: &std::path::Path, + shell: &str, +) -> Result, String> { + let hook_dir = skill_dir.join("shell-hooks"); + let (ext, rc_path) = shell_rc_info(shell); + let hook_file = hook_dir.join(format!("neuroskill.{ext}")); + + let _ = std::fs::remove_file(&hook_file); + + if let Some(ref rc) = rc_path { + if let Ok(content) = std::fs::read_to_string(rc) { + let cleaned: String = content + .lines() + .filter(|line| !line.contains("neuroskill")) + .collect::>() + .join("\n"); + let cleaned = if content.ends_with('\n') && !cleaned.ends_with('\n') { + cleaned + "\n" + } else { + cleaned + }; + std::fs::write(rc, &cleaned).map_err(|e| format!("failed to update {}: {e}", rc.display()))?; + } + } + + Ok(rc_path) +} + +/// Best-effort: remove every installed shell hook. Called from the daemon's +/// `--uninstall` path so app-removal leaves the user's rc files clean instead +/// of with stale `source ~/.skill/shell-hooks/...` lines that error out on +/// every new shell after the binaries are gone. +pub(crate) fn uninstall_all_shell_hooks(skill_dir: &std::path::Path) -> Vec<(String, String)> { + let mut report = Vec::new(); + for shell in ["zsh", "bash", "fish", "powershell"] { + match uninstall_one_shell_hook(skill_dir, shell) { + Ok(Some(rc)) => report.push((shell.to_string(), rc.display().to_string())), + Ok(None) => report.push((shell.to_string(), String::new())), + Err(e) => report.push((shell.to_string(), format!("error: {e}"))), + } + } + // Best-effort: remove the now-empty shell-hooks dir so the .skill tree is tidy. + let _ = std::fs::remove_dir(skill_dir.join("shell-hooks")); + report +} + /// Get the rc file path for a given shell. fn shell_rc_info(shell: &str) -> (&'static str, Option) { let fish_config = std::env::var_os("XDG_CONFIG_HOME") @@ -1245,13 +1317,27 @@ fn shell_rc_info(shell: &str) -> (&'static str, Option) { /// Generate the hook script content for a given shell. Self-contained — no external file deps. pub(crate) fn generate_shell_hook(shell: &str) -> String { let port = 18444; - // Absolute path to this daemon binary — the hook invokes it as ` tty ` - // so the PTY shim handles SIGWINCH propagation correctly. Auto-refresh on - // daemon startup re-bakes this path if the user moves/upgrades the app. + // The PTY shim now lives in a sibling `skill-tty` binary. Splitting it + // out means blanket process-name kills against `skill-daemon` (Tauri + // sidecar reload, kill-old-daemon-on-upgrade) no longer terminate active + // recorded shells. We bake the absolute path in so user shells survive + // PATH changes; auto-refresh on daemon startup re-bakes this path if the + // user moves/upgrades the app. + // + // Fallback for upgrades from old builds where `skill-tty` does not yet + // exist beside the daemon: invoke `skill-daemon tty`, which detects the + // sibling and execs it (and otherwise runs the in-process PTY proxy). let daemon_path = std::env::current_exe() .ok() .and_then(|p| p.to_str().map(String::from)) .unwrap_or_else(|| "skill-daemon".to_string()); + // Sibling skill-tty location — see crate::util::resolve_skill_tty_path + // for the macOS .app vs. flat-sibling layout it has to handle. + let tty_path = crate::util::resolve_skill_tty_path().and_then(|p| p.to_str().map(String::from)); + let (tty_cmd, tty_guard) = match tty_path { + Some(p) => (format!("\"{p}\""), p), + None => (format!("\"{daemon_path}\" tty"), daemon_path.clone()), + }; match shell { "zsh" => format!( r#"# NeuroSkill terminal tracking hook (zsh) @@ -1296,13 +1382,27 @@ if [[ -n "$NEUROSKILL_RECORDING" ]]; then add-zsh-hook precmd _neuroskill_set_title fi -# Session recording. The daemon's `tty` subcommand wraps the shell on a -# fresh PTY and proxies stdin/stdout while forwarding SIGWINCH correctly. -# Log path is chosen internally (under ~/.skill/terminal-logs/) so it never -# appears in argv or the terminal title. Set NEUROSKILL_RECORDING=1 to opt out. -if [[ -z "$NEUROSKILL_RECORDING" && -x "{daemon_path}" ]]; then +# Session recording. The `skill-tty` binary wraps the shell on a fresh PTY +# and proxies stdin/stdout while forwarding SIGWINCH correctly. (Older +# installs invoke `skill-daemon tty`, which now execs into skill-tty if it +# can find it.) Log path is chosen internally (under ~/.skill/terminal-logs/) +# so it never appears in argv or the terminal title. +# +# Per-terminal opt-out: set NEUROSKILL_SKIP_RECORDING=1 in the env (e.g. in +# .zshenv before it sources .zshrc, or pass it on the command line) to skip +# recording for a single shell without uninstalling the global hook. +# NEUROSKILL_RECORDING=1 is set by the wrapper itself to prevent re-entry. +# +# We intentionally avoid `exec` here: if the shim exits with code 126 it +# means startup failed (not a tty, can't open PTY, etc.) and we fall +# through to a plain interactive shell. For any other exit code (normal user +# exit, Ctrl-D, …) we forward it and close this shell too. +if [[ -z "$NEUROSKILL_RECORDING" && -z "$NEUROSKILL_SKIP_RECORDING" && -x "{tty_guard}" ]]; then export NEUROSKILL_RECORDING=1 - exec "{daemon_path}" tty + {tty_cmd} + _ns_rc=$? + unset NEUROSKILL_RECORDING + [[ $_ns_rc -ne 126 ]] && exit $_ns_rc fi "# ), @@ -1354,11 +1454,17 @@ if [[ -n "$NEUROSKILL_RECORDING" ]]; then PROMPT_COMMAND="_neuroskill_set_title;${{PROMPT_COMMAND}}" fi -# Session recording via the daemon's `tty` PTY shim (forwards SIGWINCH). +# Session recording via the `skill-tty` PTY shim (forwards SIGWINCH). +# Older installs invoke `skill-daemon tty`, which now execs into skill-tty. # Log path chosen internally — nothing leaks into argv or the tab title. -if [[ -z "$NEUROSKILL_RECORDING" && -x "{daemon_path}" ]]; then +# Set NEUROSKILL_SKIP_RECORDING=1 to skip recording for a single shell. +# See zsh block above for the fallback-on-failure rationale. +if [[ -z "$NEUROSKILL_RECORDING" && -z "$NEUROSKILL_SKIP_RECORDING" && -x "{tty_guard}" ]]; then export NEUROSKILL_RECORDING=1 - exec "{daemon_path}" tty + {tty_cmd} + _ns_rc=$? + unset NEUROSKILL_RECORDING + [[ $_ns_rc -ne 126 ]] && exit $_ns_rc fi "# ), @@ -1469,104 +1575,33 @@ async fn set_file_patterns( State(state): State, Json(patterns): Json>, ) -> Json { - let mut settings = load_user_settings(&state); - settings.file_patterns = patterns; - save_user_settings(&state, &settings); + patch_settings(&state, move |s| { + s.file_patterns = patterns; + }) + .await; Json(serde_json::json!({"ok": true})) } -async fn get_file_activity_tracking(State(state): State) -> Json { - Json(serde_json::json!({ - "value": state - .track_file_activity - .load(std::sync::atomic::Ordering::Relaxed) - })) -} - -async fn set_file_activity_tracking( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.track_file_activity = req.value; - save_user_settings(&state, &settings); - state - .track_file_activity - .store(req.value, std::sync::atomic::Ordering::Relaxed); - Json(serde_json::json!({"value": req.value})) -} - -async fn get_clipboard_tracking(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.track_clipboard})) -} - -async fn set_clipboard_tracking( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.track_clipboard = req.value; - save_user_settings(&state, &settings); - Json(serde_json::json!({"value": req.value})) -} - -async fn get_calendar_tracking(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.track_calendar})) -} - -async fn set_calendar_tracking( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.track_calendar = req.value; - save_user_settings(&state, &settings); - Json(serde_json::json!({"value": req.value})) -} - -async fn get_active_window_tracking(State(state): State) -> Json { - Json(serde_json::json!({ - "value": state - .track_active_window - .load(std::sync::atomic::Ordering::Relaxed) - })) -} - -async fn set_active_window_tracking( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.track_active_window = req.value; - save_user_settings(&state, &settings); - state - .track_active_window - .store(req.value, std::sync::atomic::Ordering::Relaxed); - Json(serde_json::json!({"value": req.value})) -} - -async fn get_input_activity_tracking(State(state): State) -> Json { - Json(serde_json::json!({ - "value": state - .track_input_activity - .load(std::sync::atomic::Ordering::Relaxed) - })) -} - -async fn set_input_activity_tracking( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.track_input_activity = req.value; - save_user_settings(&state, &settings); - state - .track_input_activity - .store(req.value, std::sync::atomic::Ordering::Relaxed); - Json(serde_json::json!({"value": req.value})) -} +crate::settings_bool_atomic!( + get_file_activity_tracking, + set_file_activity_tracking, + field: track_file_activity, + atomic: track_file_activity +); +crate::settings_bool_set_value!(get_clipboard_tracking, set_clipboard_tracking => track_clipboard); +crate::settings_bool_set_value!(get_calendar_tracking, set_calendar_tracking => track_calendar); +crate::settings_bool_atomic!( + get_active_window_tracking, + set_active_window_tracking, + field: track_active_window, + atomic: track_active_window +); +crate::settings_bool_atomic!( + get_input_activity_tracking, + set_input_activity_tracking, + field: track_input_activity, + atomic: track_input_activity +); async fn get_current_active_window(State(state): State) -> Json> { let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); @@ -2153,6 +2188,7 @@ mod tests { cat.entries.push(skill_llm::catalog::LlmModelEntry { repo: "x/y".into(), filename: "model.gguf".into(), + remote_filename: None, quant: "Q4".into(), size_gb: 1.0, description: "m".into(), @@ -2161,6 +2197,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: false, + mtp: false, recommended: false, advanced: false, params_b: 1.0, @@ -2175,6 +2212,7 @@ mod tests { cat.entries.push(skill_llm::catalog::LlmModelEntry { repo: "x/y".into(), filename: "model-mmproj-f16.gguf".into(), + remote_filename: None, quant: "F16".into(), size_gb: 0.2, description: "mm".into(), @@ -2183,6 +2221,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: true, + mtp: false, recommended: false, advanced: false, params_b: 0.0, @@ -2412,6 +2451,7 @@ mod tests { } #[tokio::test] + #[cfg(feature = "text-embeddings-fastembed")] async fn set_text_embedding_model_valid_persists() { let (td, st) = mk_state(); // Set to bge-small @@ -2477,7 +2517,7 @@ mod tests { // Subscribe to broadcast before triggering. let mut rx = st.events_tx.subscribe(); - // Trigger reembed (empty skill_dir = fast, emits loading_encoder → done). + // Trigger reembed (empty skill_dir = fast, scans first, emits done immediately). let Json(v) = trigger_reembed(State(st.clone())).await; assert_eq!(v["ok"], true); diff --git a/crates/skill-daemon/src/routes/settings_calibration.rs b/crates/skill-daemon/src/routes/settings_calibration.rs index 59af2a35..6c12b092 100644 --- a/crates/skill-daemon/src/routes/settings_calibration.rs +++ b/crates/skill-daemon/src/routes/settings_calibration.rs @@ -22,10 +22,7 @@ pub(crate) async fn list_profiles(State(state): State) -> Json) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.active_calibration_id})) -} +crate::settings_get_value!(get_active_profile_id => active_calibration_id); #[derive(Deserialize)] pub(crate) struct SetActiveRequest { diff --git a/crates/skill-daemon/src/routes/settings_device.rs b/crates/skill-daemon/src/routes/settings_device.rs index 94607b1d..e8a8dd0c 100644 --- a/crates/skill-daemon/src/routes/settings_device.rs +++ b/crates/skill-daemon/src/routes/settings_device.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use skill_eeg::eeg_filter::{FilterConfig, PowerlineFreq}; use crate::{ - routes::settings_io::{load_user_settings, save_user_settings}, + routes::settings_io::{load_user_settings, patch_settings, patch_settings_ok}, state::AppState, }; @@ -15,39 +15,22 @@ pub(crate) struct NotchPresetRequest { pub(crate) value: Option, } -#[derive(Debug, Deserialize)] -pub(crate) struct U64ValueRequest { - pub(crate) value: u64, -} - -pub(crate) async fn get_filter_config(State(state): State) -> Json { - Json(load_user_settings(&state).filter_config) -} - -pub(crate) async fn set_filter_config( - State(state): State, - Json(config): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.filter_config = config; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true})) -} +crate::settings_struct!(FilterConfig, get_filter_config, set_filter_config => filter_config); +crate::settings_get_value!(get_storage_format => storage_format); +crate::settings_get_value!(get_embedding_overlap => embedding_overlap_secs); pub(crate) async fn set_notch_preset( State(state): State, Json(req): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.filter_config.notch = req.value; - save_user_settings(&state, &settings); + let value = req.value; + let _ = patch_settings_ok(&state, move |s| { + s.filter_config.notch = value; + }) + .await; Json(serde_json::json!({"ok": true})) } -pub(crate) async fn get_storage_format(State(state): State) -> Json { - Json(serde_json::json!({"value": load_user_settings(&state).storage_format})) -} - pub(crate) async fn set_storage_format( State(state): State, Json(req): Json, @@ -57,16 +40,13 @@ pub(crate) async fn set_storage_format( "both" => "both", _ => "csv", }; - let mut settings = load_user_settings(&state); - settings.storage_format = fmt.to_string(); - save_user_settings(&state, &settings); + patch_settings(&state, move |s| { + s.storage_format = fmt.to_string(); + }) + .await; Json(serde_json::json!({"ok": true, "value": fmt})) } -pub(crate) async fn get_embedding_overlap(State(state): State) -> Json { - Json(serde_json::json!({"value": load_user_settings(&state).embedding_overlap_secs})) -} - pub(crate) async fn set_embedding_overlap( State(state): State, Json(req): Json, @@ -79,54 +59,52 @@ pub(crate) async fn set_embedding_overlap( skill_constants::EMBEDDING_OVERLAP_MIN_SECS, skill_constants::EMBEDDING_OVERLAP_MAX_SECS, ); - let mut settings = load_user_settings(&state); - settings.embedding_overlap_secs = clamped; - save_user_settings(&state, &settings); + patch_settings(&state, move |s| { + s.embedding_overlap_secs = clamped; + }) + .await; Json(serde_json::json!({"ok": true, "value": clamped})) } -pub(crate) async fn get_update_check_interval(State(state): State) -> Json { - Json(serde_json::json!({"value": load_user_settings(&state).update_check_interval_secs})) -} - -pub(crate) async fn set_update_check_interval( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.update_check_interval_secs = req.value; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true, "value": req.value})) -} +crate::settings_u64!( + get_update_check_interval, + set_update_check_interval => update_check_interval_secs +); -pub(crate) async fn get_openbci_config(State(state): State) -> Json { - Json(load_user_settings(&state).openbci) -} +crate::settings_struct_get!(skill_settings::OpenBciConfig, get_openbci_config => openbci); pub(crate) async fn set_openbci_config( State(state): State, Json(config): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.openbci = config.clone(); - save_user_settings(&state, &settings); + let wifi_shield_ip = config.wifi_shield_ip.clone(); + let galea_ip = config.galea_ip.clone(); + let _ = patch_settings_ok(&state, move |s| { + s.openbci = config; + }) + .await; if let Ok(mut wifi) = state.scanner_wifi_config.lock() { - wifi.wifi_shield_ip = config.wifi_shield_ip; - wifi.galea_ip = config.galea_ip; + wifi.wifi_shield_ip = wifi_shield_ip; + wifi.galea_ip = galea_ip; } Json(serde_json::json!({"ok": true})) } pub(crate) async fn get_device_api_config(State(state): State) -> Json { let c = load_user_settings(&state).device_api; + let (emotiv_client_id, emotiv_client_secret) = skill_settings::keychain::get_emotiv_credentials(); + let idun_api_token = skill_settings::keychain::get_idun_api_token(); + let oura_access_token = skill_settings::keychain::get_oura_access_token(); + let (neurosity_email, neurosity_password, neurosity_device_id) = + skill_settings::keychain::get_neurosity_credentials(); Json(serde_json::json!({ - "emotiv_client_id": c.emotiv_client_id, - "emotiv_client_secret": c.emotiv_client_secret, - "idun_api_token": c.idun_api_token, - "oura_access_token": c.oura_access_token, - "neurosity_email": c.neurosity_email, - "neurosity_password": c.neurosity_password, - "neurosity_device_id": c.neurosity_device_id, + "emotiv_client_id": emotiv_client_id, + "emotiv_client_secret": emotiv_client_secret, + "idun_api_token": idun_api_token, + "oura_access_token": oura_access_token, + "neurosity_email": neurosity_email, + "neurosity_password": neurosity_password, + "neurosity_device_id": neurosity_device_id, "brainmaster_model": c.brainmaster_model, })) } @@ -135,19 +113,35 @@ pub(crate) async fn set_device_api_config( State(state): State, Json(config): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.device_api = config.clone(); - save_user_settings(&state, &settings); + let emotiv_client_id = config.emotiv_client_id.clone(); + let emotiv_client_secret = config.emotiv_client_secret.clone(); + let disk_api = config.clone(); + patch_settings(&state, move |s| { + s.device_api = disk_api; + }) + .await; + skill_settings::keychain::save_device_api_secrets(&skill_settings::keychain::Secrets { + api_token: String::new(), + emotiv_client_id: config.emotiv_client_id.clone(), + emotiv_client_secret: config.emotiv_client_secret.clone(), + idun_api_token: config.idun_api_token.clone(), + oura_access_token: config.oura_access_token.clone(), + neurosity_email: config.neurosity_email.clone(), + neurosity_password: config.neurosity_password.clone(), + neurosity_device_id: config.neurosity_device_id.clone(), + }); if let Ok(mut cortex) = state.scanner_cortex_config.lock() { - cortex.emotiv_client_id = config.emotiv_client_id; - cortex.emotiv_client_secret = config.emotiv_client_secret; + cortex.emotiv_client_id = emotiv_client_id; + cortex.emotiv_client_secret = emotiv_client_secret; } Json(serde_json::json!({"ok": true})) } -pub(crate) async fn get_scanner_config(State(state): State) -> Json { - Json(load_user_settings(&state).scanner) -} +crate::settings_struct!( + skill_settings::ScannerConfig, + get_scanner_config, + set_scanner_config => scanner +); pub(crate) async fn get_device_log(State(state): State) -> Json> { let out = state @@ -158,16 +152,6 @@ pub(crate) async fn get_device_log(State(state): State) -> Json, - Json(config): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.scanner = config; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true})) -} - pub(crate) async fn list_serial_ports() -> Json> { let ports = tokio::time::timeout( std::time::Duration::from_secs(3), diff --git a/crates/skill-daemon/src/routes/settings_exg.rs b/crates/skill-daemon/src/routes/settings_exg.rs index 9022fe15..406ea590 100644 --- a/crates/skill-daemon/src/routes/settings_exg.rs +++ b/crates/skill-daemon/src/routes/settings_exg.rs @@ -49,6 +49,8 @@ pub fn probe_weights_for_config(config: &ExgModelConfig) -> Option<(String, Stri let backend = config.model_backend.as_str(); let family_id = if backend == "luna" { format!("luna-{}", config.luna_variant) + } else if backend == "eegdino" { + format!("eegdino-{}", config.eegdino_variant) } else { let families = catalog.get("families")?.as_object()?; families @@ -78,6 +80,7 @@ pub(crate) async fn trigger_reembed_impl(State(state): State) -> Json< let events_tx = state.events_tx.clone(); let model_status = state.exg_model_status.clone(); let label_index = state.label_index.clone(); + let reembed_cfg = crate::routes::settings_io::load_user_settings(&state).reembed; // Check if reembed is already running (use model_status as a simple guard). { @@ -95,9 +98,12 @@ pub(crate) async fn trigger_reembed_impl(State(state): State) -> Json< tokio::task::spawn_blocking(move || { if let Err(e) = run_batch_reembed_with_cancel( - &skill_dir, &events_tx, &cancel, true, // use_gpu - 10, // throttle_ms - 50, // batch_size + &skill_dir, + &events_tx, + &cancel, + reembed_cfg.idle_reembed_gpu, + reembed_cfg.idle_reembed_throttle_ms, + reembed_cfg.batch_size.max(1), ) { tracing::error!("batch reembed failed: {e}"); let _ = events_tx.send(skill_daemon_common::EventEnvelope { @@ -134,25 +140,6 @@ pub(crate) fn run_batch_reembed_with_cancel( ) -> anyhow::Result<()> { tracing::info!("[reembed] starting batch reembed"); - // Emit immediate feedback so the UI progress bar shows activity. - let _ = events_tx.send(skill_daemon_common::EventEnvelope { - r#type: "reembed-progress".into(), - ts_unix_ms: now_unix_ms(), - correlation_id: None, - payload: serde_json::json!({ "status": "loading_encoder", "total": 0, "done": 0 }), - }); - - // 1. Load the encoder (tries GPU first, falls back to CPU). - let config = load_model_config(skill_dir); - let encoder = crate::embed::load_encoder_public(&config, skill_dir); - if encoder.is_none() { - anyhow::bail!( - "encoder failed to load for backend '{}' — check model weights", - config.model_backend.as_str() - ); - } - let encoder = encoder.unwrap(); - let _ = events_tx.send(skill_daemon_common::EventEnvelope { r#type: "reembed-progress".into(), ts_unix_ms: now_unix_ms(), @@ -160,13 +147,14 @@ pub(crate) fn run_batch_reembed_with_cancel( payload: serde_json::json!({ "status": "scanning", "total": 0, "done": 0 }), }); - // 2. Scan all day directories for sessions with missing embeddings. + // 1. Scan first — skip the expensive encoder load if there is nothing to do. let mut total_needed = 0u64; let mut total_done = 0u64; let mut total_failed = 0u64; // Track channel counts that produced consecutive encode failures so we can // skip further attempts with the same channel count (e.g. 32-ch generic - // channels that cause GPU autotune hangs). + // channels that cause GPU autotune hangs). Reset per-day so a transient + // failure on day A doesn't permanently block day B in the same run. let mut skip_ch_counts: std::collections::HashSet = std::collections::HashSet::new(); let mut consecutive_failures_by_ch: std::collections::HashMap = std::collections::HashMap::new(); const CONSECUTIVE_FAIL_LIMIT: u32 = 5; @@ -185,7 +173,6 @@ pub(crate) fn run_batch_reembed_with_cancel( day_dirs.sort(); day_dirs.reverse(); // Most recent days first — users care about recent data. - // First pass: count total needed. for day_dir in &day_dirs { let db_path = day_dir.join(skill_constants::SQLITE_FILE); if !db_path.exists() { @@ -209,12 +196,6 @@ pub(crate) fn run_batch_reembed_with_cancel( "[reembed] {total_needed} epochs need embeddings across {} days", day_dirs.len() ); - let _ = events_tx.send(skill_daemon_common::EventEnvelope { - r#type: "reembed-progress".into(), - ts_unix_ms: now_unix_ms(), - correlation_id: None, - payload: serde_json::json!({ "status": "started", "total": total_needed, "done": 0 }), - }); if total_needed == 0 { let _ = events_tx.send(skill_daemon_common::EventEnvelope { @@ -226,8 +207,40 @@ pub(crate) fn run_batch_reembed_with_cancel( return Ok(()); } + // 2. Load the encoder only when there is actual work to do. + let _ = events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "reembed-progress".into(), + ts_unix_ms: now_unix_ms(), + correlation_id: None, + payload: serde_json::json!({ "status": "loading_encoder", "total": total_needed, "done": 0 }), + }); + + let config = load_model_config(skill_dir); + let encoder = crate::embed::load_encoder_public(&config, skill_dir); + if encoder.is_none() { + anyhow::bail!( + "encoder failed to load for backend '{}' — check model weights", + config.model_backend.as_str() + ); + } + let mut encoder = encoder.unwrap(); + + let _ = events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "reembed-progress".into(), + ts_unix_ms: now_unix_ms(), + correlation_id: None, + payload: serde_json::json!({ "status": "started", "total": total_needed, "done": 0 }), + }); + // 3. Process each day directory. for day_dir in &day_dirs { + // Reset per-channel failure tracking at each day boundary. A run of + // 5 corrupt/missing epochs on day A used to permanently disable that + // channel count for the remainder of the run — including healthy + // days B, C, D. Scope the skip to the day where it triggered. + skip_ch_counts.clear(); + consecutive_failures_by_ch.clear(); + let db_path = day_dir.join(skill_constants::SQLITE_FILE); if !db_path.exists() { continue; @@ -256,6 +269,23 @@ pub(crate) fn run_batch_reembed_with_cancel( [], ); + // Pre-existing embedding byte-length (if any). Used to validate that + // the loaded encoder still produces the same dim as what's already in + // this DB — a model swap would otherwise silently write mixed-dim + // BLOBs and ruin cosine search. + let existing_blob_len: Option = conn + .query_row( + "SELECT length(eeg_embedding) FROM embeddings \ + WHERE eeg_embedding IS NOT NULL AND length(eeg_embedding) >= 4 \ + LIMIT 1", + [], + |r| r.get::<_, i64>(0), + ) + .ok() + .map(|n| n as usize); + // Set on first successful encode and used to reject mismatched writes. + let mut day_dim_mismatch_logged = false; + // Get timestamps of epochs that need embeddings. let mut stmt = conn.prepare( "SELECT id, timestamp FROM embeddings WHERE eeg_embedding IS NULL OR length(eeg_embedding) < 4 ORDER BY timestamp", @@ -276,130 +306,220 @@ pub(crate) fn run_batch_reembed_with_cancel( epochs_needed.len() ); - // Load all raw CSV data for this day into a time-indexed buffer. - // Each segment carries its own channel names from the CSV header. - let raw_data = load_day_csv_data(day_dir, &csv_files, sample_rate); - let epoch_samples = (sample_rate * 5.0) as usize; // 5-second epoch let commit_size = batch_size.max(10); // commit every N epochs for write efficiency - // Process in transaction batches for write performance. - for chunk in epochs_needed.chunks(commit_size) { - // Check cancel flag between batches (backpressure: device reconnected). - if cancel.load(std::sync::atomic::Ordering::Relaxed) { - tracing::info!("[reembed] cancelled — device reconnected or user stopped"); - let _ = events_tx.send(skill_daemon_common::EventEnvelope { - r#type: "reembed-progress".into(), - ts_unix_ms: now_unix_ms(), - correlation_id: None, - payload: serde_json::json!({ - "status": "paused", - "total": total_needed, - "done": total_done, - "reason": "device_connected", - }), - }); - return Ok(()); + // Process one CSV segment at a time. A 24h idle window can produce + // many rollover files; loading the whole day at once makes the idle + // worker's RSS look like a leak even though the data is eventually + // dropped. + let epochs_needed_len = epochs_needed.len(); + let mut remaining_epochs = epochs_needed; + for csv_path in &csv_files { + if remaining_epochs.is_empty() { + break; } - let _ = conn.execute_batch("BEGIN"); - - for (row_id, ts_ms) in chunk { - let ts_secs = (*ts_ms as f64) / 1000.0; + // Each segment carries its own channel names from the CSV header. + let raw_data = load_day_csv_data(day_dir, std::slice::from_ref(csv_path), sample_rate); + if raw_data.segments.is_empty() { + continue; + } - let (samples, seg_ch_names) = extract_epoch_samples(&raw_data, ts_secs, epoch_samples); - if samples.is_empty() { - if total_failed == 0 { - tracing::warn!( - "[reembed] first empty extract at ts={ts_secs:.1}s (row_id={row_id}, epoch_samples={epoch_samples})", - ); - } - total_failed += 1; - total_done += 1; - continue; + let mut next_remaining = Vec::new(); + + // Process in transaction batches for write performance. + for chunk in remaining_epochs.chunks(commit_size) { + // Check cancel flag between batches (backpressure: device reconnected). + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + tracing::info!("[reembed] cancelled — device reconnected or user stopped"); + let _ = events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "reembed-progress".into(), + ts_unix_ms: now_unix_ms(), + correlation_id: None, + payload: serde_json::json!({ + "status": "paused", + "total": total_needed, + "done": total_done, + "reason": "device_connected", + }), + }); + return Ok(()); } - let n_ch = samples.len(); + let _ = conn.execute_batch("BEGIN"); - // Skip channel counts that have failed repeatedly (prevents GPU - // hangs on unsupported channel configurations). - if skip_ch_counts.contains(&n_ch) { - total_failed += 1; - total_done += 1; - continue; - } + for (row_id, ts_ms) in chunk { + let ts_secs = skill_data::util::epoch_ts_to_unix(*ts_ms) as f64; - // Log first encode attempt per channel count for diagnostics. - if total_done == 0 || (total_done > 0 && total_done == total_failed) { - tracing::info!( - "[reembed] first encode: channels={n_ch}, samples_per_ch={}, ts={ts_secs:.1}s", - samples.first().map(|s| s.len()).unwrap_or(0), - ); - } + let (samples, seg_ch_names) = extract_epoch_samples(&raw_data, ts_secs, epoch_samples); + if samples.is_empty() { + next_remaining.push((*row_id, *ts_ms)); + continue; + } - let t0 = std::time::Instant::now(); - let embedding = encode_raw_samples(&encoder, &samples, &seg_ch_names, sample_rate); - let ms = t0.elapsed().as_millis(); - - if let Some(emb) = embedding { - let blob: Vec = emb.iter().flat_map(|f| f.to_le_bytes()).collect(); - let _ = conn.execute( - "UPDATE embeddings SET eeg_embedding = ?1 WHERE id = ?2", - rusqlite::params![blob, row_id], - ); - if ms > 2000 { - tracing::warn!("[reembed] slow encode: {ms}ms for epoch {ts_ms}"); + let n_ch = samples.len(); + + // Skip channel counts that have failed repeatedly (prevents GPU + // hangs on unsupported channel configurations). + if skip_ch_counts.contains(&n_ch) { + total_failed += 1; + total_done += 1; + continue; } - // Reset failure counter on success. - consecutive_failures_by_ch.remove(&n_ch); - } else { - if total_failed == 0 { - tracing::warn!( - "[reembed] first encode failure at ts={ts_secs:.1}s (channels={n_ch}, samples_per_ch={}, rate={sample_rate}Hz)", + + // Log first encode attempt per channel count for diagnostics. + if total_done == 0 || (total_done > 0 && total_done == total_failed) { + tracing::info!( + "[reembed] first encode: channels={n_ch}, samples_per_ch={}, ts={ts_secs:.1}s", samples.first().map(|s| s.len()).unwrap_or(0), ); } - total_failed += 1; - let count = consecutive_failures_by_ch.entry(n_ch).or_insert(0); - *count += 1; - if *count >= CONSECUTIVE_FAIL_LIMIT { - tracing::warn!( - "[reembed] skipping all {n_ch}-channel epochs after {CONSECUTIVE_FAIL_LIMIT} consecutive failures" + + let t0 = std::time::Instant::now(); + let embedding = encode_raw_samples(&mut encoder, &samples, &seg_ch_names, sample_rate); + let ms = t0.elapsed().as_millis(); + + if let Some(emb) = embedding { + let blob: Vec = emb.iter().flat_map(|f| f.to_le_bytes()).collect(); + // Dim-mismatch guard: if this DB already contains + // embeddings of a different byte length, the loaded + // model is not the one that produced them. Refuse to + // write so we don't corrupt cosine search. The user + // can switch back to the matching model or wipe the + // day's embeddings to re-embed cleanly. + if let Some(expected) = existing_blob_len { + if blob.len() != expected { + if !day_dim_mismatch_logged { + tracing::error!( + "[reembed] dim mismatch in {day_name}: encoder produced {} bytes \ + but existing embeddings are {} bytes — refusing to write mixed-dim BLOBs", + blob.len(), + expected, + ); + let _ = events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "reembed-progress".into(), + ts_unix_ms: now_unix_ms(), + correlation_id: None, + payload: serde_json::json!({ + "status": "dim_mismatch", + "date": day_name, + "encoder_bytes": blob.len(), + "existing_bytes": expected, + }), + }); + day_dim_mismatch_logged = true; + } + total_failed += 1; + total_done += 1; + continue; + } + } + let _ = conn.execute( + "UPDATE embeddings SET eeg_embedding = ?1 WHERE id = ?2", + rusqlite::params![blob, row_id], ); - skip_ch_counts.insert(n_ch); + if ms > 2000 { + tracing::warn!("[reembed] slow encode: {ms}ms for epoch {ts_ms}"); + } + // Reset failure counter on success. + consecutive_failures_by_ch.remove(&n_ch); + } else { + if total_failed == 0 { + tracing::warn!( + "[reembed] first encode failure at ts={ts_secs:.1}s (channels={n_ch}, samples_per_ch={}, rate={sample_rate}Hz)", + samples.first().map(|s| s.len()).unwrap_or(0), + ); + } + total_failed += 1; + let count = consecutive_failures_by_ch.entry(n_ch).or_insert(0); + *count += 1; + if *count >= CONSECUTIVE_FAIL_LIMIT && !skip_ch_counts.contains(&n_ch) { + tracing::warn!( + "[reembed] skipping all {n_ch}-channel epochs in day {} after {CONSECUTIVE_FAIL_LIMIT} consecutive failures", + day_dir.file_name().and_then(|n| n.to_str()).unwrap_or("?"), + ); + skip_ch_counts.insert(n_ch); + // Surface to UI so users know why some epochs are + // being skipped (previously silent — looked like + // success but embeddings stayed empty). + let _ = events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "reembed-progress".into(), + ts_unix_ms: now_unix_ms(), + correlation_id: None, + payload: serde_json::json!({ + "status": "channel_skipped", + "channels": n_ch, + "date": day_dir.file_name().and_then(|n| n.to_str()).unwrap_or("?"), + "consecutive_failures": *count, + }), + }); + } } + + total_done += 1; } - total_done += 1; + let _ = conn.execute_batch("COMMIT"); + + // Emit progress every batch. + let _ = events_tx.send(skill_daemon_common::EventEnvelope { + r#type: "reembed-progress".into(), + ts_unix_ms: now_unix_ms(), + correlation_id: None, + payload: serde_json::json!({ + "status": "running", + "total": total_needed, + "done": total_done, + "failed": total_failed, + "date": day_name, + }), + }); + + // Throttle between batches to reduce contention with other daemon tasks. + if throttle_ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(throttle_ms)); + } } - let _ = conn.execute_batch("COMMIT"); + remaining_epochs = next_remaining; + } - // Emit progress every batch. + if !remaining_epochs.is_empty() { + // These epochs have no matching CSV segment (timestamp falls + // outside every loaded segment, or a 5s window straddles a + // segment boundary and neither side has enough samples). + // Previously these stayed NULL in the DB so the next idle-reembed + // tick re-tried them, failed again, then took a 1h backoff — + // looking from the outside like reembed had stopped working. + // Log + emit so users (and we) can see *why*. + let day_name = day_dir.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + let sample_ids: Vec = remaining_epochs.iter().take(5).map(|(id, _)| *id).collect(); + tracing::warn!( + "[reembed] {day_name}: {} epoch(s) have no matching CSV segment (sample row ids: {sample_ids:?}, epoch_samples={epoch_samples})", + remaining_epochs.len(), + ); let _ = events_tx.send(skill_daemon_common::EventEnvelope { r#type: "reembed-progress".into(), ts_unix_ms: now_unix_ms(), correlation_id: None, payload: serde_json::json!({ - "status": "running", - "total": total_needed, - "done": total_done, - "failed": total_failed, + "status": "unrecoverable", "date": day_name, + "count": remaining_epochs.len(), + "sample_row_ids": sample_ids, + "reason": "no_csv_segment_for_timestamp", }), }); - - // Throttle between batches to reduce contention with other daemon tasks. - if throttle_ms > 0 { - std::thread::sleep(std::time::Duration::from_millis(throttle_ms)); - } + total_failed += remaining_epochs.len() as u64; + total_done += remaining_epochs.len() as u64; } tracing::info!( "[reembed] {} done: {}/{} epochs embedded", day_dir.file_name().and_then(|n| n.to_str()).unwrap_or("?"), total_done - total_failed, - epochs_needed.len() + epochs_needed_len ); } @@ -419,29 +539,52 @@ pub(crate) fn run_batch_reembed_with_cancel( Ok(()) } -/// Find exg_*.csv files (raw EEG, not metrics/ppg/imu). +/// Find exg_*.{csv,parquet} files (raw EEG, not metrics/ppg/imu/fnirs). +/// +/// Returns both CSV and Parquet recordings. If a recording was written in +/// `storage_format = "both"`, both files appear here — `load_day_csv_data` +/// dedupes by (start_ts, channel layout) so duplicates don't produce +/// duplicate segments. If only parquet exists (the user picked +/// `storage_format = "parquet"`), it is read via `skill_data::session_parquet`. fn find_eeg_csvs(day_dir: &std::path::Path) -> Vec { - let mut csvs: Vec<_> = std::fs::read_dir(day_dir) + let mut files: Vec<_> = std::fs::read_dir(day_dir) .into_iter() .flatten() .flatten() .filter_map(|e| { let p = e.path(); let name = p.file_name()?.to_str()?; - if name.starts_with("exg_") - && name.ends_with(".csv") + let is_eeg = name.starts_with("exg_") + && (name.ends_with(".csv") || name.ends_with(".parquet")) && !name.contains("metrics") && !name.contains("ppg") && !name.contains("imu") - { + && !name.contains("fnirs"); + if is_eeg { Some(p) } else { None } }) .collect(); - csvs.sort(); - csvs + files.sort(); + // If both .csv and .parquet exist for the same session timestamp, prefer + // CSV (the legacy default) and drop the parquet sibling so we don't + // double-load identical samples. + let csv_stems: std::collections::HashSet = files + .iter() + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("csv")) + .filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(String::from)) + .collect(); + files.retain(|p| { + if p.extension().and_then(|e| e.to_str()) == Some("parquet") { + let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + !csv_stems.contains(stem) + } else { + true + } + }); + files } /// Read channel names and sample rate from JSON sidecar or CSV header. @@ -477,6 +620,11 @@ fn read_session_meta(_day_dir: &std::path::Path, csv_files: &[std::path::PathBuf } // Fallback: read CSV header to get channel names. for csv in csv_files { + if csv.extension().and_then(|e| e.to_str()) == Some("parquet") { + // Parquet fallback handled below — the loaded data carries its + // own schema, so we only need to return *some* sample rate. + continue; + } if let Ok(file) = std::fs::File::open(csv) { use std::io::BufRead; let mut reader = std::io::BufReader::new(file); @@ -492,6 +640,24 @@ fn read_session_meta(_day_dir: &std::path::Path, csv_files: &[std::path::PathBuf } } } + // Last-ditch fallback for parquet-only sessions with no JSON sidecar: + // read channel names from the first parquet file's schema. Sample rate + // defaults to 256 Hz (Muse standard) — load_day_csv_data computes the + // actual segment length from row counts, so a 256 Hz default just means + // the epoch-window math uses 256 samples/s. If the recording was at a + // different rate the epochs will silently be wrong width, but that + // matches existing CSV behavior. A real fix is to embed sample_rate in + // the parquet metadata when writing. + for path in csv_files { + if path.extension().and_then(|e| e.to_str()) != Some("parquet") { + continue; + } + if let Ok(ch) = skill_data::session_parquet::read_eeg_parquet_channels(path) { + if !ch.is_empty() { + return (ch, 256.0); + } + } + } (vec![], 0.0) } @@ -504,10 +670,59 @@ struct RawDayData { /// Load all raw CSV data for a day directory. /// Each segment stores its own channel names read from the CSV header. +/// +/// Rows are capped per file AND across the whole day to keep memory bounded +/// once session rollover produces many chunk files per day. Without the +/// per-day cap, a 24-hour recording with hourly rollover would attempt to +/// load 24 × per-file-cap rows into RAM. fn load_day_csv_data(_day_dir: &std::path::Path, csv_files: &[std::path::PathBuf], sample_rate: f64) -> RawDayData { let mut segments = Vec::new(); + // Day-wide cap: ≈ 4.3 hours at 256 Hz, plenty for any UI preview. + const MAX_DAY_ROWS: usize = 4_000_000; + let mut day_row_count: usize = 0; for csv_path in csv_files { + if day_row_count >= MAX_DAY_ROWS { + break; + } + + // Parquet branch — read via skill-data helper. Channel names come + // from the parquet schema; the first timestamp comes from row 0. + // Without this branch a `storage_format = "parquet"` user would have + // every day skipped and every epoch left un-embedded. + if csv_path.extension().and_then(|e| e.to_str()) == Some("parquet") { + match skill_data::session_parquet::read_eeg_parquet(csv_path) { + Ok((mut ts, seg_channel_names, channels)) => { + if channels.is_empty() || channels[0].is_empty() { + continue; + } + // Mirror the CSV branch's relative-timestamp fixup. + if ts < 1_000_000_000.0 { + if let Some(start) = csv_path + .file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| s.strip_prefix("exg_")) + .and_then(|s| s.parse::().ok()) + { + ts = start; + } + } + let rows = channels[0].len(); + if day_row_count.saturating_add(rows) > MAX_DAY_ROWS { + // Soft cap — keep whatever we already have and stop + // scanning further parquet files for this day. + break; + } + day_row_count += rows; + segments.push((ts, seg_channel_names, channels)); + } + Err(e) => { + tracing::warn!("[reembed] parquet read failed for {}: {e:#}", csv_path.display()); + } + } + continue; + } + let Ok(file) = std::fs::File::open(csv_path) else { continue; }; @@ -534,13 +749,13 @@ fn load_day_csv_data(_day_dir: &std::path::Path, csv_files: &[std::path::PathBuf } let mut channels: Vec> = vec![Vec::new(); file_ch]; let mut first_ts: Option = None; - // Cap rows to prevent OOM on very large CSV files (~4M samples at - // 256 Hz ≈ 4.3 hours, well beyond a single session). + // Per-file cap (legacy single-file-per-session safety net). The + // day-wide cap above bounds memory across rollover chunks. const MAX_ROWS: usize = 4_000_000; let mut row_count = 0usize; for line in lines.map_while(Result::ok) { - if row_count >= MAX_ROWS { + if row_count >= MAX_ROWS || day_row_count >= MAX_DAY_ROWS { break; } let fields: Vec<&str> = line.split(',').collect(); @@ -568,6 +783,7 @@ fn load_day_csv_data(_day_dir: &std::path::Path, csv_files: &[std::path::PathBuf channels[ch].push(v); } row_count += 1; + day_row_count += 1; } } @@ -621,13 +837,30 @@ fn extract_epoch_samples(data: &RawDayData, epoch_ts_secs: f64, epoch_samples: u } /// Encode raw samples using the loaded encoder. +/// +/// Wrapped in `catch_unwind` so a single bad epoch (e.g. burn/wgpu validation +/// panic on an unusual channel count) does not abort the whole batch-reembed +/// task. Idle reembed runs in `spawn_blocking` and a panic there is invisible. fn encode_raw_samples( - encoder: &crate::embed::PublicEncoder, + encoder: &mut crate::embed::PublicEncoder, samples: &[Vec], channel_names: &[String], sample_rate: f64, ) -> Option> { - crate::embed::encode_raw_public(encoder, samples, channel_names, sample_rate) + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + crate::embed::encode_raw_public(encoder, samples, channel_names, sample_rate) + })); + match result { + Ok(emb) => emb, + Err(_) => { + tracing::warn!( + channels = channel_names.len(), + sample_rate, + "[reembed] encode panicked — skipping epoch" + ); + None + } + } } pub(crate) async fn trigger_weights_download_impl(State(state): State) -> Json { @@ -762,6 +995,8 @@ fn resolve_download_target(catalog: &serde_json::Value, config: &ExgModelConfig) let family_id = if backend == "luna" { format!("luna-{}", config.luna_variant) + } else if backend == "eegdino" { + format!("eegdino-{}", config.eegdino_variant) } else { let families = catalog.get("families").and_then(|f| f.as_object()); if let Some(fams) = families { @@ -780,8 +1015,17 @@ fn resolve_download_target(catalog: &serde_json::Value, config: &ExgModelConfig) .get("weights_file") .and_then(|v| v.as_str()) .unwrap_or("model-00001-of-00001.safetensors"); - let cf = fam.get("config_file").and_then(|v| v.as_str()).unwrap_or("config.json"); + let cf = fam + .get("config_file") + .and_then(|v| if v.is_null() { None } else { v.as_str() }) + .unwrap_or("config.json"); (repo.to_string(), wf.to_string(), cf.to_string()) + } else if backend == "eegdino" { + ( + config.eegdino_hf_repo.clone(), + config.eegdino_weights_file().to_string(), + String::new(), + ) } else if backend == "luna" { ( config.luna_hf_repo.clone(), @@ -813,6 +1057,9 @@ fn family_id_to_backend(id: &str) -> &str { if id.starts_with("steegformer-") { return "steegformer"; } + if id.starts_with("eegdino-") { + return "eegdino"; + } id } @@ -959,6 +1206,19 @@ pub(crate) async fn get_exg_catalog_impl(State(state): State) -> Json< "LUNA".to_string() } } + skill_eeg::eeg_model_config::ExgModelBackend::Eegdino => { + if let Some(fam) = v + .get("families") + .and_then(|f| f.get(format!("eegdino-{}", config.eegdino_variant))) + { + fam.get("name") + .and_then(|n| n.as_str()) + .unwrap_or("EEG-DINO") + .to_string() + } else { + "EEG-DINO".to_string() + } + } _ => { let backend_str = config.model_backend.as_str(); if let Some(families) = v.get("families").and_then(|f| f.as_object()) { @@ -1022,6 +1282,7 @@ mod tests { assert_eq!(family_id_to_backend("reve-base"), "reve"); assert_eq!(family_id_to_backend("osf-base"), "osf"); assert_eq!(family_id_to_backend("steegformer-x"), "steegformer"); + assert_eq!(family_id_to_backend("eegdino-small"), "eegdino"); } // ── Helpers for reembed tests ───────────────────────────────────────── @@ -1086,6 +1347,40 @@ mod tests { assert!(find_eeg_csvs(td.path()).is_empty()); } + #[test] + fn find_eeg_csvs_includes_parquet() { + let td = tempfile::tempdir().unwrap(); + let d = td.path(); + std::fs::write(d.join("exg_100.parquet"), "").unwrap(); + std::fs::write(d.join("exg_100_metrics.parquet"), "").unwrap(); + std::fs::write(d.join("exg_100_ppg.parquet"), "").unwrap(); + std::fs::write(d.join("exg_100_imu.parquet"), "").unwrap(); + std::fs::write(d.join("exg_100_fnirs.parquet"), "").unwrap(); + std::fs::write(d.join("exg_200.parquet"), "").unwrap(); + + let files = find_eeg_csvs(d); + let names: Vec<&str> = files.iter().filter_map(|p| p.file_name()?.to_str()).collect(); + assert_eq!(names, vec!["exg_100.parquet", "exg_200.parquet"]); + } + + #[test] + fn find_eeg_csvs_dedupes_csv_and_parquet_siblings() { + let td = tempfile::tempdir().unwrap(); + let d = td.path(); + // storage_format = "both" produces both files for the same session. + std::fs::write(d.join("exg_100.csv"), "").unwrap(); + std::fs::write(d.join("exg_100.parquet"), "").unwrap(); + // Parquet-only session. + std::fs::write(d.join("exg_200.parquet"), "").unwrap(); + // CSV-only session. + std::fs::write(d.join("exg_300.csv"), "").unwrap(); + + let files = find_eeg_csvs(d); + let names: Vec<&str> = files.iter().filter_map(|p| p.file_name()?.to_str()).collect(); + // CSV preferred when both exist; parquet kept when CSV absent. + assert_eq!(names, vec!["exg_100.csv", "exg_200.parquet", "exg_300.csv"]); + } + // ── read_session_meta ───────────────────────────────────────────────── #[test] @@ -1280,6 +1575,46 @@ mod tests { assert_eq!(data.segments[0].2[0].len(), 2); // only 2 valid rows } + /// Day-wide row cap holds across rollover chunk files. Build several + /// chunks whose combined row count exceeds the cap and verify that the + /// loader stops collecting rather than loading everything into memory. + #[test] + fn load_day_csv_data_caps_rows_across_chunks() { + let td = tempfile::tempdir().unwrap(); + let d = td.path(); + + // Tiny rows to keep test fast — we patch the cap behavior by + // inspecting the *aggregated* row count, not by hitting the real + // 4M cap. We assert each chunk fully loads when below the cap, then + // assert that across many chunks the total stays ≤ MAX_DAY_ROWS. + // Realistic check: produce a number well under per-file cap (4M) + // but over what we reasonably need so the test detects regressions + // in the *combined* counting logic. + let per_chunk = 1000usize; + let n_chunks = 5usize; + let mut csvs = Vec::new(); + for k in 0..n_chunks { + let start = 1_700_000_000.0 + (k as f64) * 10_000.0; + let rows = gen_rows(start, per_chunk, 4, 256.0); + let name = format!("exg_{}.csv", 1_700_000_000 + k as u64 * 10_000); + write_csv(d, &name, muse_header(), &rows); + csvs.push(d.join(&name)); + } + + let data = load_day_csv_data(d, &csvs, 256.0); + + // All segments load when total well under cap. + assert_eq!(data.segments.len(), n_chunks); + let total_samples: usize = data.segments.iter().map(|s| s.2[0].len()).sum(); + assert_eq!(total_samples, per_chunk * n_chunks); + + // The day_row_count guard short-circuits new files once the cap is + // hit. We can only exercise that branch with a giant fixture, but + // the structural change is covered by the segment-count assertion + // (every chunk was visited and loaded — i.e. the loop did not + // erroneously stop after the first file). + } + #[test] fn load_csv_nan_values_skip_row() { let td = tempfile::tempdir().unwrap(); @@ -1495,4 +1830,62 @@ mod tests { assert_eq!(samples[0].len(), 1280); assert_eq!(ch[0], "Ch1"); } + + /// Mirrors the per-CSV `remaining_epochs` loop: an epoch in a later rollover file + /// must survive an empty extract on the first segment and succeed on the second. + #[test] + fn per_csv_segment_defers_epochs_until_matching_file() { + let td = tempfile::tempdir().unwrap(); + let d = td.path(); + let sample_rate = 256.0; + let epoch_samples = (sample_rate * 5.0) as usize; + + // Segment 1: ~2s of Muse data — too short for a 5s window at t=2005. + write_csv(d, "exg_100.csv", muse_header(), &gen_rows(100.0, 512, 4, sample_rate)); + // Segment 2: 20s starting at 2000 — contains a full 5s epoch at 2005. + write_csv( + d, + "exg_2000.csv", + muse_header(), + &gen_rows(2000.0, 5120, 4, sample_rate), + ); + + let csvs = find_eeg_csvs(d); + assert_eq!(csvs.len(), 2); + + let target_ts = 2005.0; + let mut remaining = vec![(1i64, target_ts)]; + + for csv_path in &csvs { + if remaining.is_empty() { + break; + } + let raw_data = load_day_csv_data(d, std::slice::from_ref(csv_path), sample_rate); + if raw_data.segments.is_empty() { + continue; + } + let mut next_remaining = Vec::new(); + for (row_id, ts_secs) in &remaining { + let (samples, _) = extract_epoch_samples(&raw_data, *ts_secs, epoch_samples); + if samples.is_empty() { + next_remaining.push((*row_id, *ts_secs)); + } + } + remaining = next_remaining; + } + + assert!( + remaining.is_empty(), + "epoch at {target_ts}s should embed from the second CSV only" + ); + + let first_only = load_day_csv_data(d, std::slice::from_ref(&csvs[0]), sample_rate); + let (samples, _) = extract_epoch_samples(&first_only, target_ts, epoch_samples); + assert!(samples.is_empty(), "first segment alone must not satisfy the epoch"); + + let second_only = load_day_csv_data(d, std::slice::from_ref(&csvs[1]), sample_rate); + let (samples, ch) = extract_epoch_samples(&second_only, target_ts, epoch_samples); + assert_eq!(samples.len(), 4); + assert_eq!(ch, vec!["TP9", "AF7", "AF8", "TP10"]); + } } diff --git a/crates/skill-daemon/src/routes/settings_handlers.rs b/crates/skill-daemon/src/routes/settings_handlers.rs new file mode 100644 index 00000000..1d15a674 --- /dev/null +++ b/crates/skill-daemon/src/routes/settings_handlers.rs @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Declarative macros for repetitive settings GET/SET axum handlers. + +/// GET handler returning `{"value": …}` for a nested settings path. +#[macro_export] +macro_rules! settings_nested_get_value { + ($get:ident => $($path:ident).+) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + let settings = $crate::routes::settings_io::load_user_settings(&state); + axum::Json(serde_json::json!({ "value": settings.$($path).+ })) + } + }; +} + +/// GET handler returning `{"value": …}` for a settings field. +#[macro_export] +macro_rules! settings_get_value { + ($get:ident => $field:ident) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + axum::Json(serde_json::json!({ + "value": $crate::routes::settings_io::load_user_settings(&state).$field + })) + } + }; +} + +/// Bool field; setter returns `{"value": …}` (activity-tracking API shape). +#[macro_export] +macro_rules! settings_bool_set_value { + ($get:ident, $set:ident => $field:ident) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + axum::Json(serde_json::json!({ + "value": $crate::routes::settings_io::load_user_settings(&state).$field + })) + } + + pub(crate) async fn $set( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + axum::Json(req): axum::Json<$crate::routes::settings::BoolValueRequest>, + ) -> axum::Json { + let value = req.value; + $crate::routes::settings_io::modify_settings_blocking(&state, move |s| { + s.$field = value; + }) + .await; + axum::Json(serde_json::json!({ "value": value })) + } + }; +} + +/// Bool in settings plus a matching `AppState` atomic; getter reads the atomic. +#[macro_export] +macro_rules! settings_bool_atomic { + ($get:ident, $set:ident, field: $field:ident, atomic: $atomic:ident) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + axum::Json(serde_json::json!({ + "value": state.$atomic.load(std::sync::atomic::Ordering::Relaxed) + })) + } + + pub(crate) async fn $set( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + axum::Json(req): axum::Json<$crate::routes::settings::BoolValueRequest>, + ) -> axum::Json { + let value = req.value; + $crate::routes::settings_io::modify_settings_blocking(&state, move |s| { + s.$field = value; + }) + .await; + state.$atomic.store(value, std::sync::atomic::Ordering::Relaxed); + axum::Json(serde_json::json!({ "value": value })) + } + }; +} + +/// String field, JSON `{"value": …}` / setter `{"ok": true}`. +#[macro_export] +macro_rules! settings_string { + ($get:ident, $set:ident => $field:ident) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + axum::Json(serde_json::json!({ + "value": $crate::routes::settings_io::load_user_settings(&state).$field + })) + } + + pub(crate) async fn $set( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + axum::Json(req): axum::Json<$crate::routes::settings::StringValueRequest>, + ) -> axum::Json { + let value = req.value; + $crate::routes::settings_io::modify_settings_blocking(&state, move |s| { + s.$field = value; + }) + .await; + axum::Json(serde_json::json!({ "ok": true })) + } + }; +} + +/// Bool field stored in `UserSettings`, JSON `{"value": …}` / `{"ok": true, "value": …}`. +#[macro_export] +macro_rules! settings_bool { + ($get:ident, $set:ident => $field:ident) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + axum::Json(serde_json::json!({ + "value": $crate::routes::settings_io::load_user_settings(&state).$field + })) + } + + pub(crate) async fn $set( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + axum::Json(req): axum::Json<$crate::routes::settings::BoolValueRequest>, + ) -> axum::Json { + let value = req.value; + $crate::routes::settings_io::modify_settings_blocking(&state, move |s| { + s.$field = value; + }) + .await; + axum::Json(serde_json::json!({ "ok": true, "value": value })) + } + }; +} + +/// GET handler returning a config struct field. +#[macro_export] +macro_rules! settings_struct_get { + ($ty:ty, $get:ident => $field:ident) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json<$ty> { + axum::Json($crate::routes::settings_io::load_user_settings(&state).$field) + } + }; +} + +/// Whole config struct as request/response body. +#[macro_export] +macro_rules! settings_struct { + ($ty:ty, $get:ident, $set:ident => $field:ident) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json<$ty> { + axum::Json($crate::routes::settings_io::load_user_settings(&state).$field) + } + + pub(crate) async fn $set( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + axum::Json(config): axum::Json<$ty>, + ) -> axum::Json { + $crate::routes::settings_io::patch_settings_ok(&state, move |s| { + s.$field = config; + }) + .await + } + }; +} + +/// Bool field at a nested path (`settings.llm.tools.foo`). +#[macro_export] +macro_rules! settings_nested_bool { + ($get:ident, $set:ident => $($path:ident).+) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + let settings = $crate::routes::settings_io::load_user_settings(&state); + axum::Json(serde_json::json!({ "value": settings.$($path).+ })) + } + + pub(crate) async fn $set( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + axum::Json(req): axum::Json<$crate::routes::settings::BoolValueRequest>, + ) -> axum::Json { + let value = req.value; + $crate::routes::settings_io::modify_settings_blocking(&state, move |s| { + s.$($path).+ = value; + }) + .await; + axum::Json(serde_json::json!({ "ok": true, "value": value })) + } + }; +} + +/// `u64` scalar in settings (device routes). +#[macro_export] +macro_rules! settings_u64 { + ($get:ident, $set:ident => $field:ident) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + axum::Json(serde_json::json!({ + "value": $crate::routes::settings_io::load_user_settings(&state).$field + })) + } + + pub(crate) async fn $set( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + axum::Json(req): axum::Json<$crate::routes::settings::U64ValueRequest>, + ) -> axum::Json { + let value = req.value; + $crate::routes::settings_io::modify_settings_blocking(&state, move |s| { + s.$field = value; + }) + .await; + axum::Json(serde_json::json!({ "ok": true, "value": value })) + } + }; +} + +/// `u64` field at a nested path. +#[macro_export] +macro_rules! settings_nested_u64 { + ($get:ident, $set:ident => $($path:ident).+) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + let settings = $crate::routes::settings_io::load_user_settings(&state); + axum::Json(serde_json::json!({ "value": settings.$($path).+ })) + } + + pub(crate) async fn $set( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + axum::Json(req): axum::Json<$crate::routes::settings::U64ValueRequest>, + ) -> axum::Json { + let value = req.value; + $crate::routes::settings_io::modify_settings_blocking(&state, move |s| { + s.$($path).+ = value; + }) + .await; + axum::Json(serde_json::json!({ "ok": true, "value": value })) + } + }; +} + +/// `u64` scalar in settings (UI routes). +#[macro_export] +macro_rules! settings_u64_ui { + ($get:ident, $set:ident => $field:ident) => { + pub(crate) async fn $get( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + ) -> axum::Json { + axum::Json(serde_json::json!({ + "value": $crate::routes::settings_io::load_user_settings(&state).$field + })) + } + + pub(crate) async fn $set( + axum::extract::State(state): axum::extract::State<$crate::state::AppState>, + axum::Json(req): axum::Json<$crate::routes::settings::U64ValueRequest>, + ) -> axum::Json { + let value = req.value; + $crate::routes::settings_io::modify_settings_blocking(&state, move |s| { + s.$field = value; + }) + .await; + axum::Json(serde_json::json!({ "ok": true, "value": value })) + } + }; +} diff --git a/crates/skill-daemon/src/routes/settings_hooks_activity.rs b/crates/skill-daemon/src/routes/settings_hooks_activity.rs index dfc0715b..f68ffcee 100644 --- a/crates/skill-daemon/src/routes/settings_hooks_activity.rs +++ b/crates/skill-daemon/src/routes/settings_hooks_activity.rs @@ -9,9 +9,12 @@ use skill_data::activity_store::{ }; use crate::{ - routes::settings::{ - ActivityBucketsRequest, ActivityFilesRequest, ActivityRecentRequest, CoEditRequest, DaySummaryRequest, - EditChunksRequest, HookDistanceRequest, HookKeywordsRequest, HookLogRequest, + routes::{ + settings::{ + ActivityBucketsRequest, ActivityFilesRequest, ActivityRecentRequest, CoEditRequest, DaySummaryRequest, + EditChunksRequest, HookDistanceRequest, HookKeywordsRequest, HookLogRequest, + }, + settings_io::patch_user_settings_sync, }, state::AppState, }; @@ -27,15 +30,10 @@ pub(crate) async fn set_hooks_impl( if let Ok(mut g) = state.hooks.lock() { *g = hooks.clone(); } - let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let mut settings = skill_settings::load_settings(&skill_dir); - settings.hooks = hooks; - let path = skill_settings::settings_path(&skill_dir); - let ok = serde_json::to_string_pretty(&settings) - .ok() - .and_then(|json| std::fs::write(path, json).ok()) - .is_some(); - Json(serde_json::json!({"ok": ok})) + patch_user_settings_sync(&state, move |s| { + s.hooks = hooks; + }); + Json(serde_json::json!({"ok": true})) } pub(crate) async fn get_hook_statuses_impl(State(state): State) -> Json { diff --git a/crates/skill-daemon/src/routes/settings_io.rs b/crates/skill-daemon/src/routes/settings_io.rs index 47e8eba1..4e341432 100644 --- a/crates/skill-daemon/src/routes/settings_io.rs +++ b/crates/skill-daemon/src/routes/settings_io.rs @@ -7,21 +7,32 @@ use crate::state::AppState; /// Prevents TOCTOU races when multiple handlers modify settings concurrently. static SETTINGS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); -pub(crate) fn load_user_settings(state: &AppState) -> skill_settings::UserSettings { +fn patch_settings_locked_with(skill_dir: &std::path::Path, generation: &std::sync::atomic::AtomicU64, f: F) -> R +where + F: FnOnce(&mut skill_settings::UserSettings) -> R, +{ + let _guard = SETTINGS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut settings = skill_settings::load_settings(skill_dir); + let result = f(&mut settings); + let path = skill_settings::settings_path(skill_dir); + if let Ok(json) = serde_json::to_string_pretty(&settings) { + let _ = std::fs::write(path, json); + } + generation.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + result +} + +/// Synchronous RMW under [`SETTINGS_LOCK`] (hooks/LSL sync paths). +pub(crate) fn patch_user_settings_sync(state: &AppState, patch: impl FnOnce(&mut skill_settings::UserSettings)) { let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - skill_settings::load_settings(&skill_dir) + patch_settings_locked_with(&skill_dir, &state.settings_generation, |s| { + patch(s); + }); } -pub(crate) fn save_user_settings(state: &AppState, settings: &skill_settings::UserSettings) { +pub(crate) fn load_user_settings(state: &AppState) -> skill_settings::UserSettings { let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let path = skill_settings::settings_path(&skill_dir); - if let Ok(json) = serde_json::to_string_pretty(settings) { - let _ = std::fs::write(path, json); - } - // Bump the generation counter so background threads can detect the change. - state - .settings_generation - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + skill_settings::load_settings(&skill_dir) } /// Async-safe wrapper: atomically load, modify, and save settings under a @@ -34,17 +45,24 @@ where { let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); let gen = state.settings_generation.clone(); - tokio::task::spawn_blocking(move || { - let _guard = SETTINGS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let mut settings = skill_settings::load_settings(&skill_dir); - let result = f(&mut settings); - let path = skill_settings::settings_path(&skill_dir); - if let Ok(json) = serde_json::to_string_pretty(&settings) { - let _ = std::fs::write(path, json); - } - gen.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - result - }) - .await - .unwrap_or_default() + tokio::task::spawn_blocking(move || patch_settings_locked_with(&skill_dir, &gen, f)) + .await + .unwrap_or_default() +} + +/// Load–modify–save under [`SETTINGS_LOCK`] on the blocking pool (no return value). +pub(crate) async fn patch_settings( + state: &AppState, + patch: impl FnOnce(&mut skill_settings::UserSettings) + Send + 'static, +) { + modify_settings_blocking(state, patch).await; +} + +/// [`patch_settings`] then `{"ok": true}`. +pub(crate) async fn patch_settings_ok( + state: &AppState, + patch: impl FnOnce(&mut skill_settings::UserSettings) + Send + 'static, +) -> axum::Json { + patch_settings(state, patch).await; + axum::Json(serde_json::json!({ "ok": true })) } diff --git a/crates/skill-daemon/src/routes/settings_llm.rs b/crates/skill-daemon/src/routes/settings_llm.rs index bfc9e277..99f8ab22 100644 --- a/crates/skill-daemon/src/routes/settings_llm.rs +++ b/crates/skill-daemon/src/routes/settings_llm.rs @@ -6,14 +6,12 @@ use axum::{extract::State, Json}; use crate::{ routes::{ settings::StringValueRequest, - settings_io::{load_user_settings, save_user_settings}, + settings_io::{load_user_settings, modify_settings_blocking}, }, state::AppState, }; -pub(crate) async fn get_llm_config(State(state): State) -> Json { - Json(load_user_settings(&state).llm) -} +crate::settings_struct_get!(skill_settings::LlmConfig, get_llm_config => llm); pub(crate) async fn set_llm_config( State(state): State, @@ -25,9 +23,13 @@ pub(crate) async fn set_llm_config( #[cfg(feature = "llm")] let prev_ctx_size = state.llm_config.lock().map(|g| g.ctx_size).ok(); - let mut settings = load_user_settings(&state); - settings.llm = config.clone(); - save_user_settings(&state, &settings); + let location_enabled = load_user_settings(&state).location_enabled; + let config = config.clone(); + let disk_llm = config.clone(); + modify_settings_blocking(&state, move |s| { + s.llm = disk_llm; + }) + .await; #[cfg(feature = "llm")] { @@ -44,7 +46,7 @@ pub(crate) async fn set_llm_config( let prev_port = server.allowed_tools.lock().map(|t| t.skill_api_port).unwrap_or(18445); let mut new_tools = config.tools.clone(); new_tools.skill_api_port = prev_port; - if !settings.location_enabled { + if !location_enabled { new_tools.location = false; } if let Ok(mut tools) = server.allowed_tools.lock() { @@ -69,36 +71,38 @@ pub(crate) async fn set_llm_config( Json(serde_json::json!({"ok": true})) } -pub(crate) async fn get_inference_device(State(state): State) -> Json { - Json(serde_json::json!({"value": load_user_settings(&state).inference_device})) -} +crate::settings_get_value!(get_inference_device => inference_device); pub(crate) async fn set_inference_device( State(state): State, Json(req): Json, ) -> Json { let is_cpu = req.value == "cpu"; - let mut settings = load_user_settings(&state); - settings.inference_device = if is_cpu { "cpu".into() } else { "gpu".into() }; - if is_cpu { - let cur_layers = settings.llm.n_gpu_layers; - if cur_layers != 0 { - settings.llm_gpu_layers_saved = cur_layers; + let out = modify_settings_blocking(&state, move |s| { + s.inference_device = if is_cpu { "cpu".into() } else { "gpu".into() }; + if is_cpu { + let cur_layers = s.llm.n_gpu_layers; + if cur_layers != 0 { + s.llm_gpu_layers_saved = cur_layers; + } + s.llm.n_gpu_layers = 0; + } else { + s.llm.n_gpu_layers = s.llm_gpu_layers_saved; } - settings.llm.n_gpu_layers = 0; - } else { - settings.llm.n_gpu_layers = settings.llm_gpu_layers_saved; - } - let out = settings.inference_device.clone(); + s.inference_device.clone() + }) + .await; // Sync to in-memory llm_config so the next server start picks it up. #[cfg(feature = "llm")] if let Ok(mut cfg) = state.llm_config.lock() { - cfg.n_gpu_layers = settings.llm.n_gpu_layers; + cfg.n_gpu_layers = if is_cpu { + 0 + } else { + load_user_settings(&state).llm_gpu_layers_saved + }; } - save_user_settings(&state, &settings); - // If the LLM server is already running, restart it so the device // change takes effect immediately. #[cfg(feature = "llm")] @@ -118,24 +122,24 @@ pub(crate) async fn set_inference_device( Json(serde_json::json!({"ok": true, "value": out})) } -pub(crate) async fn get_exg_inference_device(State(state): State) -> Json { - Json(serde_json::json!({"value": load_user_settings(&state).exg_inference_device})) -} +crate::settings_get_value!(get_exg_inference_device => exg_inference_device); pub(crate) async fn set_exg_inference_device( State(state): State, Json(req): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.exg_inference_device = match req.value.as_str() { + let out: String = match req.value.as_str() { "mlx" => "mlx".into(), "gpu" => "gpu".into(), "cpu" => "cpu".into(), _ => "auto".into(), }; - let out = settings.exg_inference_device.clone(); - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true, "value": out})) + let out_clone = out.clone(); + modify_settings_blocking(&state, move |s| { + s.exg_inference_device = out; + }) + .await; + Json(serde_json::json!({"ok": true, "value": out_clone})) } pub(crate) async fn get_hf_endpoint(State(state): State) -> Json { @@ -152,12 +156,15 @@ pub(crate) async fn set_hf_endpoint( State(state): State, Json(req): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.hf_endpoint = if req.value.trim().is_empty() { + let endpoint = if req.value.trim().is_empty() { skill_settings::default_hf_endpoint() } else { req.value.trim().to_string() }; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true, "value": settings.hf_endpoint})) + let endpoint_out = endpoint.clone(); + modify_settings_blocking(&state, move |s| { + s.hf_endpoint = endpoint; + }) + .await; + Json(serde_json::json!({"ok": true, "value": endpoint_out})) } diff --git a/crates/skill-daemon/src/routes/settings_llm_runtime.rs b/crates/skill-daemon/src/routes/settings_llm_runtime.rs index ddbbe9d1..bfe6f311 100644 --- a/crates/skill-daemon/src/routes/settings_llm_runtime.rs +++ b/crates/skill-daemon/src/routes/settings_llm_runtime.rs @@ -8,7 +8,7 @@ use crate::{ settings::{ BoolValueRequest, FilenameRequest, HfFilesParams, HfSearchParams, LlmAddModelRequest, LlmFilenameRequest, }, - settings_io::{load_user_settings, save_user_settings}, + settings_io::{modify_settings_blocking, patch_user_settings_sync}, }, state::AppState, }; @@ -231,9 +231,9 @@ pub(crate) async fn llm_server_start_impl(State(state): State) -> Json if let Ok(mut g) = state.llm_config.lock() { g.enabled = true; } - let mut settings = load_user_settings(&state); - settings.llm.enabled = true; - save_user_settings(&state, &settings); + patch_user_settings_sync(&state, |s| { + s.llm.enabled = true; + }); } if cell.lock().ok().and_then(|g| g.clone()).is_some() { @@ -308,9 +308,9 @@ pub(crate) async fn llm_server_stop_impl(State(state): State) -> Json< if let Ok(mut g) = state.llm_config.lock() { g.enabled = false; } - let mut settings = load_user_settings(&state); - settings.llm.enabled = false; - save_user_settings(&state, &settings); + patch_user_settings_sync(&state, |s| { + s.llm.enabled = false; + }); } if let Ok(mut st) = state.llm_status.lock() { *st = "stopped".to_string(); @@ -657,6 +657,7 @@ pub(crate) async fn llm_add_model_impl( let entry = skill_llm::catalog::LlmModelEntry { repo: req.repo.clone(), filename: req.filename.clone(), + remote_filename: None, quant: infer_quant(&req.filename), size_gb: req.size_gb.unwrap_or(0.0), description: "External model".to_string(), @@ -677,6 +678,7 @@ pub(crate) async fn llm_add_model_impl( tags: vec!["external".to_string()], is_mmproj: req.mmproj.as_ref().map(|m| m == &req.filename).unwrap_or(false) || req.filename.to_ascii_lowercase().contains("mmproj"), + mtp: false, recommended: false, advanced: false, params_b: 0.0, @@ -896,9 +898,11 @@ pub(crate) async fn llm_set_autoload_mmproj_impl( State(state): State, Json(req): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.llm.autoload_mmproj = req.value; - save_user_settings(&state, &settings); + let value = req.value; + modify_settings_blocking(&state, move |s| { + s.llm.autoload_mmproj = value; + }) + .await; #[cfg(feature = "llm")] { if let Ok(mut cfg) = state.llm_config.lock() { @@ -984,6 +988,7 @@ mod tests { cat.entries.push(skill_llm::catalog::LlmModelEntry { repo: "a/b".into(), filename: "model.gguf".into(), + remote_filename: None, quant: "Q4".into(), size_gb: 1.0, description: String::new(), @@ -992,6 +997,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: false, + mtp: false, recommended: false, advanced: false, params_b: 1.0, @@ -1035,6 +1041,7 @@ mod tests { cat.entries.push(skill_llm::catalog::LlmModelEntry { repo: "a/b".into(), filename: "model-a.gguf".into(), + remote_filename: None, quant: "Q4".into(), size_gb: 1.0, description: String::new(), @@ -1043,6 +1050,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: false, + mtp: false, recommended: false, advanced: false, params_b: 1.0, @@ -1057,6 +1065,7 @@ mod tests { cat.entries.push(skill_llm::catalog::LlmModelEntry { repo: "a/b".into(), filename: "model-b-mmproj.gguf".into(), + remote_filename: None, quant: "F16".into(), size_gb: 0.2, description: String::new(), @@ -1065,6 +1074,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: true, + mtp: false, recommended: false, advanced: false, params_b: 0.0, diff --git a/crates/skill-daemon/src/routes/settings_lsl.rs b/crates/skill-daemon/src/routes/settings_lsl.rs index 0a0f8685..385c0bb5 100644 --- a/crates/skill-daemon/src/routes/settings_lsl.rs +++ b/crates/skill-daemon/src/routes/settings_lsl.rs @@ -4,7 +4,7 @@ use axum::{extract::State, Json}; use serde::Deserialize; -use crate::state::AppState; +use crate::{routes::settings_io::patch_user_settings_sync, state::AppState}; #[derive(Debug, Deserialize)] pub(crate) struct LslAutoConnectRequest { @@ -33,19 +33,18 @@ pub(crate) struct LslIdleTimeoutRequest { } fn persist_lsl_settings(state: &AppState) { - let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let mut settings = skill_settings::load_settings(&skill_dir); - settings.lsl_auto_connect = state.lsl_auto_connect.lock().map(|g| *g).unwrap_or(false); - settings.lsl_paired_streams = state.lsl_paired_streams.lock().map(|g| g.clone()).unwrap_or_default(); - settings.lsl_idle_timeout_secs = state + let auto_connect = state.lsl_auto_connect.lock().map(|g| *g).unwrap_or(false); + let paired_streams = state.lsl_paired_streams.lock().map(|g| g.clone()).unwrap_or_default(); + let idle_secs = state .lsl_idle_timeout_secs .lock() .map(|g| *g) .unwrap_or(skill_settings::default_lsl_idle_timeout_secs()); - let path = skill_settings::settings_path(&skill_dir); - if let Ok(json) = serde_json::to_string_pretty(&settings) { - let _ = std::fs::write(path, json); - } + patch_user_settings_sync(state, move |s| { + s.lsl_auto_connect = auto_connect; + s.lsl_paired_streams = paired_streams; + s.lsl_idle_timeout_secs = idle_secs; + }); } pub(crate) async fn get_lsl_config(State(state): State) -> Json { diff --git a/crates/skill-daemon/src/routes/settings_screenshots.rs b/crates/skill-daemon/src/routes/settings_screenshots.rs index 16b2a1c1..66f43e57 100644 --- a/crates/skill-daemon/src/routes/settings_screenshots.rs +++ b/crates/skill-daemon/src/routes/settings_screenshots.rs @@ -5,7 +5,7 @@ use axum::{extract::State, Json}; use serde::Deserialize; use crate::{ - routes::settings_io::{load_user_settings, save_user_settings}, + routes::settings_io::{load_user_settings, modify_settings_blocking}, state::AppState, }; @@ -95,15 +95,17 @@ pub(crate) async fn set_screenshot_config( State(state): State, Json(config): Json, ) -> Json { - let mut settings = load_user_settings(&state); - let old_backend = settings.screenshot.embed_backend.clone(); - let old_model = settings.screenshot.model_id(); + let old = load_user_settings(&state).screenshot; + let old_backend = old.embed_backend.clone(); + let old_model = old.model_id(); let new_backend = config.embed_backend.clone(); let new_model = config.model_id(); let model_changed = old_backend != new_backend || old_model != new_model; - settings.screenshot = config; - save_user_settings(&state, &settings); + modify_settings_blocking(&state, move |s| { + s.screenshot = config; + }) + .await; let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); let stale_count = if model_changed { @@ -181,34 +183,81 @@ pub(crate) async fn get_screenshots_around( Json(out) } +#[cfg(feature = "text-embeddings-fastembed")] +fn image_search_inner( + settings: skill_settings::UserSettings, + skill_dir: std::path::PathBuf, + req: ScreenshotImageSearchRequest, +) -> Vec { + let Some(mut encoder) = skill_screenshots::capture::load_fastembed_image_pub(&settings.screenshot, &skill_dir) + else { + return vec![]; + }; + let Some(query) = skill_screenshots::capture::fastembed_embed_pub(&mut encoder, &req.image_bytes) else { + return vec![]; + }; + let Some(store) = skill_data::screenshot_store::ScreenshotStore::open(&skill_dir) else { + return vec![]; + }; + let hnsw_path = skill_dir.join(skill_constants::SCREENSHOTS_HNSW); + let Ok(hnsw) = fast_hnsw::labeled::LabeledIndex::::load( + &hnsw_path, + fast_hnsw::distance::Cosine, + ) else { + return vec![]; + }; + skill_screenshots::capture::search_by_vector(&hnsw, &store, &query, req.k) +} + +#[cfg(all(not(feature = "text-embeddings-fastembed"), feature = "text-embeddings-rlx"))] +fn image_search_inner( + settings: skill_settings::UserSettings, + skill_dir: std::path::PathBuf, + req: ScreenshotImageSearchRequest, +) -> Vec { + let device = settings.text_embedding_rlx_device.clone(); + let encoder = match skill_screenshots::rlx_image::RlxImageEmbedder::from_repo(&device) { + Ok(e) => e, + Err(e) => { + eprintln!("[search] rlx image embedder load failed: {e}"); + return vec![]; + } + }; + let Some(query) = encoder.embed_bytes(&req.image_bytes) else { + return vec![]; + }; + let Some(store) = skill_data::screenshot_store::ScreenshotStore::open(&skill_dir) else { + return vec![]; + }; + let hnsw_path = skill_dir.join(skill_constants::SCREENSHOTS_HNSW); + let Ok(hnsw) = fast_hnsw::labeled::LabeledIndex::::load( + &hnsw_path, + fast_hnsw::distance::Cosine, + ) else { + return vec![]; + }; + skill_screenshots::capture::search_by_vector(&hnsw, &store, &query, req.k) +} + +#[cfg(all(not(feature = "text-embeddings-fastembed"), not(feature = "text-embeddings-rlx")))] +fn image_search_inner( + _settings: skill_settings::UserSettings, + _skill_dir: std::path::PathBuf, + _req: ScreenshotImageSearchRequest, +) -> Vec { + // No image-embed backend compiled in. Returns empty. + vec![] +} + pub(crate) async fn search_screenshots_by_image( State(state): State, Json(req): Json, ) -> Json> { let settings = load_user_settings(&state); let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let out = tokio::task::spawn_blocking(move || { - let Some(mut encoder) = skill_screenshots::capture::load_fastembed_image_pub(&settings.screenshot, &skill_dir) - else { - return vec![]; - }; - let Some(query) = skill_screenshots::capture::fastembed_embed_pub(&mut encoder, &req.image_bytes) else { - return vec![]; - }; - let Some(store) = skill_data::screenshot_store::ScreenshotStore::open(&skill_dir) else { - return vec![]; - }; - let hnsw_path = skill_dir.join(skill_constants::SCREENSHOTS_HNSW); - let Ok(hnsw) = fast_hnsw::labeled::LabeledIndex::::load( - &hnsw_path, - fast_hnsw::distance::Cosine, - ) else { - return vec![]; - }; - skill_screenshots::capture::search_by_vector(&hnsw, &store, &query, req.k) - }) - .await - .unwrap_or_default(); + let out = tokio::task::spawn_blocking(move || image_search_inner(settings, skill_dir, req)) + .await + .unwrap_or_default(); Json(out) } diff --git a/crates/skill-daemon/src/routes/settings_ui.rs b/crates/skill-daemon/src/routes/settings_ui.rs index 58943aba..44c60c21 100644 --- a/crates/skill-daemon/src/routes/settings_ui.rs +++ b/crates/skill-daemon/src/routes/settings_ui.rs @@ -5,11 +5,11 @@ use axum::{extract::State, Json}; use serde::Deserialize; use crate::{ - routes::settings_io::{load_user_settings, save_user_settings}, + routes::settings_io::{load_user_settings, modify_settings_blocking, patch_settings}, state::AppState, }; -use super::settings::{BoolValueRequest, StringValueRequest}; +use super::settings::{BoolValueRequest, StringValueRequest, U64ValueRequest}; #[derive(Debug, Deserialize)] pub(crate) struct WsConfigRequest { @@ -17,11 +17,6 @@ pub(crate) struct WsConfigRequest { pub(crate) port: u16, } -#[derive(Debug, Deserialize)] -pub(crate) struct U64ValueRequest { - pub(crate) value: u64, -} - #[derive(Debug, Deserialize)] pub(crate) struct StringListRequest { pub(crate) values: Vec, @@ -51,55 +46,22 @@ pub(crate) async fn set_iroh_logs( state .iroh_logs_enabled .store(req.value, std::sync::atomic::Ordering::Relaxed); - let mut settings = load_user_settings(&state); - settings.iroh_logs = req.value; - save_user_settings(&state, &settings); + patch_settings(&state, move |s| { + s.iroh_logs = req.value; + }) + .await; Json(serde_json::json!({"ok": true})) } // --- TTS / Sleep / WS --- -pub(crate) async fn get_neutts_config(State(state): State) -> Json { - Json(load_user_settings(&state).neutts) -} - -pub(crate) async fn set_neutts_config( - State(state): State, - Json(config): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.neutts = config; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true})) -} - -pub(crate) async fn get_tts_preload(State(state): State) -> Json { - Json(serde_json::json!({"value": load_user_settings(&state).tts_preload})) -} - -pub(crate) async fn set_tts_preload( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.tts_preload = req.value; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true, "value": req.value})) -} - -pub(crate) async fn get_sleep_config(State(state): State) -> Json { - Json(load_user_settings(&state).sleep) -} - -pub(crate) async fn set_sleep_config( - State(state): State, - Json(config): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.sleep = config; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true})) -} +crate::settings_struct!( + skill_settings::NeuttsConfig, + get_neutts_config, + set_neutts_config => neutts +); +crate::settings_bool!(get_tts_preload, set_tts_preload => tts_preload); +crate::settings_struct!(skill_settings::SleepConfig, get_sleep_config, set_sleep_config => sleep); pub(crate) async fn get_ws_config(State(state): State) -> Json { let settings = load_user_settings(&state); @@ -121,19 +83,17 @@ pub(crate) async fn set_ws_config( serde_json::json!({"ok": false, "error": format!("port {} is reserved; use 1024–65535", req.port)}), ); } - let mut settings = load_user_settings(&state); - settings.ws_host = host; - settings.ws_port = req.port; - save_user_settings(&state, &settings); + patch_settings(&state, move |s| { + s.ws_host = host; + s.ws_port = req.port; + }) + .await; Json(serde_json::json!({"ok": true, "port": req.port})) } // --- Location / Token --- -pub(crate) async fn get_location_enabled(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.location_enabled})) -} +crate::settings_get_value!(get_location_enabled => location_enabled); pub(crate) async fn set_location_enabled( State(state): State, @@ -141,9 +101,10 @@ pub(crate) async fn set_location_enabled( ) -> Json { use serde_json::json; if !req.value { - let mut settings = load_user_settings(&state); - settings.location_enabled = false; - save_user_settings(&state, &settings); + patch_settings(&state, move |s| { + s.location_enabled = false; + }) + .await; return Json(json!({"enabled": false})); } @@ -205,9 +166,10 @@ pub(crate) async fn set_location_enabled( .and_then(serde_json::Value::as_bool) .unwrap_or(false); if enabled_result { - let mut settings = load_user_settings(&state); - settings.location_enabled = true; - save_user_settings(&state, &settings); + patch_settings(&state, move |s| { + s.location_enabled = true; + }) + .await; } Json(result) @@ -235,18 +197,15 @@ pub(crate) async fn test_location() -> Json { Json(v) } -pub(crate) async fn get_api_token(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.api_token})) +pub(crate) async fn get_api_token(State(_state): State) -> Json { + Json(serde_json::json!({"value": skill_settings::keychain::get_api_token()})) } pub(crate) async fn set_api_token( - State(state): State, + State(_state): State, Json(req): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.api_token = req.value; - save_user_settings(&state, &settings); + skill_settings::keychain::set_api_token(&req.value); Json(serde_json::json!({"ok": true})) } @@ -354,20 +313,11 @@ pub(crate) async fn get_dnd_focus_modes() -> Json) -> Json { - let settings = load_user_settings(&state); - Json(settings.do_not_disturb) -} - -pub(crate) async fn set_dnd_config( - State(state): State, - Json(config): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.do_not_disturb = config; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true})) -} +crate::settings_struct!( + skill_settings::DoNotDisturbConfig, + get_dnd_config, + set_dnd_config => do_not_disturb +); pub(crate) async fn get_dnd_active() -> Json { Json(serde_json::json!({"value": skill_data::dnd::query_os_active().unwrap_or(false)})) @@ -423,98 +373,36 @@ pub(crate) async fn open_full_disk_access() -> Json { // --- UI appearance --- -pub(crate) async fn get_accent_color(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.accent_color})) -} - -pub(crate) async fn set_accent_color( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.accent_color = req.value; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true})) -} +crate::settings_string!(get_accent_color, set_accent_color => accent_color); -pub(crate) async fn get_daily_goal(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.daily_goal_min})) -} +crate::settings_get_value!(get_daily_goal => daily_goal_min); pub(crate) async fn set_daily_goal( State(state): State, Json(req): Json, ) -> Json { - let mut settings = load_user_settings(&state); let clamped = (req.value as u32).min(480); - settings.daily_goal_min = clamped; - save_user_settings(&state, &settings); + modify_settings_blocking(&state, move |s| { + s.daily_goal_min = clamped; + }) + .await; Json(serde_json::json!({"ok": true, "value": clamped})) } -pub(crate) async fn get_goal_notified_date(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.goal_notified_date})) -} - -pub(crate) async fn set_goal_notified_date( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.goal_notified_date = req.value; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true})) -} +crate::settings_string!(get_goal_notified_date, set_goal_notified_date => goal_notified_date); -pub(crate) async fn get_main_window_auto_fit(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.main_window_auto_fit})) -} - -pub(crate) async fn set_main_window_auto_fit( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.main_window_auto_fit = req.value; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true, "value": req.value})) -} +crate::settings_bool!(get_main_window_auto_fit, set_main_window_auto_fit => main_window_auto_fit); // --- Skills --- -pub(crate) async fn get_skills_refresh_interval(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.llm.tools.skills_refresh_interval_secs})) -} - -pub(crate) async fn set_skills_refresh_interval( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.llm.tools.skills_refresh_interval_secs = req.value; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true, "value": req.value})) -} - -pub(crate) async fn get_skills_sync_on_launch(State(state): State) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.llm.tools.skills_sync_on_launch})) -} - -pub(crate) async fn set_skills_sync_on_launch( - State(state): State, - Json(req): Json, -) -> Json { - let mut settings = load_user_settings(&state); - settings.llm.tools.skills_sync_on_launch = req.value; - save_user_settings(&state, &settings); - Json(serde_json::json!({"ok": true, "value": req.value})) -} +crate::settings_nested_u64!( + get_skills_refresh_interval, + set_skills_refresh_interval => llm.tools.skills_refresh_interval_secs +); +crate::settings_nested_bool!( + get_skills_sync_on_launch, + set_skills_sync_on_launch => llm.tools.skills_sync_on_launch +); pub(crate) async fn get_skills_last_sync(State(state): State) -> Json { let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); @@ -589,18 +477,17 @@ pub(crate) async fn get_skills_license(State(state): State) -> Json) -> Json { - let settings = load_user_settings(&state); - Json(serde_json::json!({"value": settings.llm.tools.disabled_skills})) -} +crate::settings_nested_get_value!(get_disabled_skills => llm.tools.disabled_skills); pub(crate) async fn set_disabled_skills( State(state): State, Json(req): Json, ) -> Json { - let mut settings = load_user_settings(&state); - settings.llm.tools.disabled_skills = req.values.clone(); - save_user_settings(&state, &settings); + let values = req.values.clone(); + modify_settings_blocking(&state, move |s| { + s.llm.tools.disabled_skills = values; + }) + .await; Json(serde_json::json!({"ok": true, "value": req.values})) } diff --git a/crates/skill-daemon/src/scanner.rs b/crates/skill-daemon/src/scanner.rs index a0bd4332..66b9b558 100644 --- a/crates/skill-daemon/src/scanner.rs +++ b/crates/skill-daemon/src/scanner.rs @@ -9,7 +9,7 @@ use futures::StreamExt; use skill_daemon_common::{DiscoveredDeviceResponse, ScannerWifiConfigRequest}; use tokio::sync::oneshot; -use tracing::debug; +use tracing::{debug, info}; use crate::state::AppState; use crate::util::{now_unix_secs, push_device_log}; @@ -601,12 +601,8 @@ pub(crate) fn detect_manual_device_hints(state: &AppState) -> Vec break, _ = tick.tick() => { + let tick_start = std::time::Instant::now(); // Timeout serial port enumeration — on Windows the FTDI // driver can occasionally stall `serialport::available_ports()` // for 10+ seconds when a dongle is mid-reset. Without a @@ -856,6 +867,40 @@ pub(crate) async fn run_usb_scanner_task(state: AppState, mut stop_rx: oneshot:: "scanner", &format!("scan tick discovered {} devices", discovered_count), ); + + // Adaptive backoff: count this tick as "empty" only when we + // have no paired devices to reconnect to AND no transport + // turned anything up. With paired devices we keep the fast + // cadence so a quick plug-in gets reconnected promptly. + let has_paired = state + .status + .lock() + .map(|s| !s.paired_devices.is_empty()) + .unwrap_or(false); + if discovered_count == 0 && !has_paired { + empty_ticks = empty_ticks.saturating_add(1); + } else { + if current_period != FAST_TICK { + info!("[scanner] device activity — returning to fast scan cadence"); + tick = tokio::time::interval(FAST_TICK); + // Skip the immediate first tick (interval fires once on creation). + tick.tick().await; + current_period = FAST_TICK; + } + empty_ticks = 0; + } + if empty_ticks >= EMPTY_TICKS_BEFORE_BACKOFF && current_period != SLOW_TICK { + info!( + "[scanner] no devices found in {} ticks and none paired — backing off to {}s cadence", + empty_ticks, + SLOW_TICK.as_secs() + ); + tick = tokio::time::interval(SLOW_TICK); + tick.tick().await; + current_period = SLOW_TICK; + } + + state.record_task_heartbeat("device-scanner", tick_start.elapsed().as_millis() as u64); } } } diff --git a/crates/skill-daemon/src/session/connect_ble.rs b/crates/skill-daemon/src/session/connect_ble.rs index 5764aca2..0d58214a 100644 --- a/crates/skill-daemon/src/session/connect_ble.rs +++ b/crates/skill-daemon/src/session/connect_ble.rs @@ -116,19 +116,13 @@ pub(super) async fn connect_hermes(paired_name: Option) -> anyhow::Resul // ── IDUN Guardian (BLE) ────────────────────────────────────────────────────── pub(super) async fn connect_idun( - state: &AppState, + _state: &AppState, paired_name: Option, ) -> anyhow::Result> { use skill_devices::idun::prelude::*; use skill_devices::session::idun::IdunAdapter; - let api_token = { - let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - skill_settings::load_settings(&skill_dir) - .device_api - .idun_api_token - .clone() - }; + let api_token = skill_settings::keychain::get_idun_api_token(); info!("connecting to IDUN Guardian…"); let config = GuardianClientConfig { diff --git a/crates/skill-daemon/src/session/connect_wired.rs b/crates/skill-daemon/src/session/connect_wired.rs index 7f7a9b3c..e7182be7 100644 --- a/crates/skill-daemon/src/session/connect_wired.rs +++ b/crates/skill-daemon/src/session/connect_wired.rs @@ -538,33 +538,32 @@ impl skill_devices::session::DeviceAdapter for NeuroSkyAdapter { // ── Neurosity Crown/Notion (Cloud API) ───────────────────────────────────── -pub(super) async fn connect_neurosity(state: &AppState, target: &str) -> anyhow::Result> { +pub(super) async fn connect_neurosity(_state: &AppState, target: &str) -> anyhow::Result> { use neurosity::prelude::*; let requested_device_id = target.strip_prefix("neurosity:").unwrap_or("").trim().to_string(); let (device_id, email, password) = { - let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let settings = skill_settings::load_settings(&skill_dir); + let (kc_email, kc_password, kc_device_id) = skill_settings::keychain::get_neurosity_credentials(); let device_id = if requested_device_id.is_empty() { - settings.device_api.neurosity_device_id.clone() + kc_device_id } else { requested_device_id }; - let email = if settings.device_api.neurosity_email.trim().is_empty() { + let email = if kc_email.trim().is_empty() { std::env::var("SKILL_NEUROSITY_EMAIL") .or_else(|_| std::env::var("NEUROSITY_EMAIL")) .unwrap_or_default() } else { - settings.device_api.neurosity_email.clone() + kc_email }; - let password = if settings.device_api.neurosity_password.trim().is_empty() { + let password = if kc_password.trim().is_empty() { std::env::var("SKILL_NEUROSITY_PASSWORD") .or_else(|_| std::env::var("NEUROSITY_PASSWORD")) .unwrap_or_default() } else { - settings.device_api.neurosity_password.clone() + kc_password }; (device_id, email, password) @@ -913,18 +912,11 @@ pub(super) async fn connect_antneuro(state: &AppState, target: &str) -> anyhow:: // ── Emotiv (Cortex WebSocket API) ──────────────────────────────────────────── -pub(super) async fn connect_emotiv(state: &AppState) -> anyhow::Result> { +pub(super) async fn connect_emotiv(_state: &AppState) -> anyhow::Result> { use skill_devices::emotiv::prelude::*; use skill_devices::session::emotiv::EmotivAdapter; - let (client_id, client_secret) = { - let skill_dir = state.skill_dir.lock().map(|g| g.clone()).unwrap_or_default(); - let settings = skill_settings::load_settings(&skill_dir); - ( - settings.device_api.emotiv_client_id.clone(), - settings.device_api.emotiv_client_secret.clone(), - ) - }; + let (client_id, client_secret) = skill_settings::keychain::get_emotiv_credentials(); if client_id.trim().is_empty() || client_secret.trim().is_empty() { anyhow::bail!("Emotiv client_id/client_secret not configured in Settings → Device API"); diff --git a/crates/skill-daemon/src/session/mod.rs b/crates/skill-daemon/src/session/mod.rs index 9014ac99..9504d51e 100644 --- a/crates/skill-daemon/src/session/mod.rs +++ b/crates/skill-daemon/src/session/mod.rs @@ -10,6 +10,7 @@ mod connect; mod connect_ble; mod connect_wired; pub(crate) mod pipeline; +pub(crate) mod retention; mod runner; pub(crate) mod shared; diff --git a/crates/skill-daemon/src/session/pipeline.rs b/crates/skill-daemon/src/session/pipeline.rs index f8f44830..4e61904f 100644 --- a/crates/skill-daemon/src/session/pipeline.rs +++ b/crates/skill-daemon/src/session/pipeline.rs @@ -15,7 +15,7 @@ use skill_settings::HookRule; use tokio::sync::broadcast; use tracing::info; -use super::shared::{enrich_band_snapshot, unix_secs, utc_date_dir, write_session_meta}; +use super::shared::{enrich_band_snapshot, unix_secs, utc_date_dir, write_session_meta, write_session_meta_partial}; use crate::embed::{EmbedWorkerHandle, EpochAccumulator}; // ── Epoch metrics store ────────────────────────────────────────────────────── @@ -108,6 +108,10 @@ impl Pipeline { // DSP pipeline: filter → bands → quality → artifacts. let filter_config = { let settings = skill_settings::load_settings(skill_dir); + // Propagate the EXG device preference to the RLX FFT kernels so the + // filter and band-analyzer use Metal on macOS, CUDA → wgpu → CPU elsewhere. + #[cfg(feature = "embed-exg")] + skill_eeg::rlx_fft::init_device(crate::embed::resolve_exg_device(&settings.exg_inference_device)); let mut cfg = settings.filter_config; cfg.sample_rate = sample_rate as f32; cfg @@ -270,8 +274,31 @@ impl Pipeline { self.quality.all_qualities() } + /// Write an in-progress sidecar JSON next to the CSV. + /// + /// Called immediately after `open` (once device identity has been + /// threaded in by the runner) and after every `roll`. The next call + /// to `finalize` overwrites it with the complete sidecar. Purpose: + /// a daemon killed mid-chunk leaves a usable sidecar so + /// `list_sessions_for_day` doesn't fall back to CSV-header sniffing. + pub(crate) fn write_partial_sidecar(&self) { + write_session_meta_partial( + &self.csv_path, + &self.device_name, + &self.channel_names, + self.sample_rate, + self.start_utc, + &crate::session::shared::SessionDeviceId { + firmware_version: self.firmware_version.as_deref(), + serial_number: self.serial_number.as_deref(), + }, + &self.device_kind, + ); + } + pub(crate) fn finalize(&mut self) { self.writer.flush(); + self.writer.close(); write_session_meta( &self.csv_path, &self.device_name, @@ -291,6 +318,57 @@ impl Pipeline { "session finalized" ); } + + /// Roll the session writer to a new file. Finalises the current chunk + /// (writes sidecar JSON, closes Parquet footer) and opens a fresh + /// `exg_.csv|parquet`. Keeps DSP, embedding, and PPG/artifact state + /// warm — only the writer is swapped. + /// + /// To downstream readers each chunk looks identical to a normal short + /// session — same naming, same sidecar shape — so no readers need to + /// know about rollover. + pub(crate) fn roll(&mut self, skill_dir: &Path) -> anyhow::Result<()> { + // 1. Finalise the current chunk (writer flush+close, sidecar JSON). + let just_closed = self.csv_path.clone(); + self.finalize(); + // Pre-warm the metrics cache for the just-closed chunk in a + // background thread. With hourly rollover, an overnight 8h + // recording produces ~480 chunks; without pre-warming, the first + // history-page load cold-builds all caches synchronously. + std::thread::spawn(move || { + let _ = skill_history::load_csv_metrics_cached(&just_closed); + }); + + // 2. Compute a new csv_path. unix_secs() granularity is 1s; if a + // rollover lands inside the same second as the previous start, + // bump by 1 to keep filenames unique. + let day_dir = utc_date_dir(skill_dir); + let now = unix_secs(); + let new_start = if now > self.start_utc { now } else { self.start_utc + 1 }; + let new_path = day_dir.join(format!("exg_{new_start}.csv")); + + // 3. Open a fresh writer with the same labels and current settings. + let storage_format = { + let settings = skill_settings::load_settings(skill_dir); + StorageFormat::parse(&settings.storage_format) + }; + let labels: Vec<&str> = self.channel_names.iter().map(String::as_str).collect(); + let new_writer = SessionWriter::open(&new_path, &labels, storage_format).context("rollover writer open")?; + + // 4. Swap. + self.writer = new_writer; + self.csv_path = new_path; + self.start_utc = new_start; + self.total_samples = 0; + self.flush_counter = 0; + + // 5. Drop a partial sidecar for the new chunk so a crash before + // the next finalize still leaves a readable session entry. + self.write_partial_sidecar(); + + info!(path = %self.csv_path.display(), "session rolled"); + Ok(()) + } } #[cfg(test)] @@ -460,4 +538,325 @@ mod tests { let q = pipe.channel_quality(); assert_eq!(q.len(), 4); } + + /// Rollover finalises the current chunk and opens a fresh one with a + /// distinct path, while preserving DSP/embed state. Both chunks must be + /// readable independently. + #[test] + fn pipeline_roll_finalizes_and_opens_new_chunk() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 2, + 128.0, + vec!["Ch1".into(), "Ch2".into()], + "RollDevice".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + + // Write some samples to chunk 1. + for i in 0..30 { + pipe.push_eeg(&[1.0, 2.0], i as f64 / 128.0); + } + let chunk1_path = pipe.csv_path.clone(); + let chunk1_start = pipe.start_utc; + + // Roll. + pipe.roll(dir.path()).unwrap(); + + // After roll: counters reset, path differs, start_utc strictly greater. + assert_eq!(pipe.total_samples, 0); + assert_ne!(pipe.csv_path, chunk1_path); + assert!(pipe.start_utc > chunk1_start, "new start_utc must advance"); + + // Write samples to chunk 2. + for i in 0..20 { + pipe.push_eeg(&[3.0, 4.0], i as f64 / 128.0); + } + assert_eq!(pipe.total_samples, 20); + let chunk2_path = pipe.csv_path.clone(); + + pipe.finalize(); + + // Both CSVs and both sidecars exist. + assert!(chunk1_path.exists(), "chunk1 csv"); + assert!(chunk2_path.exists(), "chunk2 csv"); + let m1: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(chunk1_path.with_extension("json")).unwrap()).unwrap(); + let m2: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(chunk2_path.with_extension("json")).unwrap()).unwrap(); + assert_eq!(m1["total_samples"], 30); + assert_eq!(m2["total_samples"], 20); + assert_eq!(m1["device_name"], "RollDevice"); + assert_eq!(m2["device_name"], "RollDevice"); + } + + /// Partial sidecar must be writable before finalize and must contain + /// the device/channel/rate fields needed by `list_sessions_for_day`. + #[test] + fn write_partial_sidecar_creates_in_progress_json() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 4, + 256.0, + vec!["TP9".into(), "AF7".into(), "AF8".into(), "TP10".into()], + "PartialDevice".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + + // Simulate the runner enriching device identity, then write partial. + pipe.firmware_version = Some("fw-2.1.3".into()); + pipe.serial_number = Some("SN-XYZ".into()); + pipe.device_kind = "muse".into(); + pipe.write_partial_sidecar(); + + let sidecar = pipe.csv_path.with_extension("json"); + assert!(sidecar.exists(), "partial sidecar must exist before finalize"); + let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap(); + + assert_eq!(v["device_name"], "PartialDevice"); + assert_eq!(v["sample_rate_hz"], 256.0); + assert_eq!(v["device_kind"], "muse"); + assert_eq!(v["firmware_version"], "fw-2.1.3"); + assert_eq!(v["serial_number"], "SN-XYZ"); + assert_eq!(v["channel_count"], 4); + assert_eq!(v["channel_names"][0], "TP9"); + assert_eq!(v["in_progress"], true); + assert_eq!(v["total_samples"], 0); + assert!(v.get("session_start_utc").and_then(|x| x.as_u64()).is_some()); + + // Now push samples and finalize: full sidecar must overwrite + // (in_progress flag dropped, total_samples populated). + for i in 0..32 { + pipe.push_eeg(&[1.0, 2.0, 3.0, 4.0], i as f64 / 256.0); + } + pipe.finalize(); + + let v2: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap(); + assert_eq!(v2["device_name"], "PartialDevice"); + assert_eq!(v2["total_samples"], 32); + assert!(v2.get("in_progress").is_none(), "in_progress flag dropped on finalize"); + assert!(v2.get("session_end_utc").and_then(|x| x.as_u64()).is_some()); + } + + /// After Pipeline::roll, the new chunk must have a partial sidecar + /// before any samples are written, just like the initial open. + #[test] + fn pipeline_roll_writes_partial_sidecar_for_new_chunk() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 2, + 128.0, + vec!["Ch1".into(), "Ch2".into()], + "RollPartial".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + pipe.device_kind = "test".into(); + pipe.write_partial_sidecar(); + + // Roll without writing any samples to chunk 2. + pipe.roll(dir.path()).unwrap(); + + // Sidecar for chunk 2 must already exist from the partial write. + let chunk2_sidecar = pipe.csv_path.with_extension("json"); + assert!(chunk2_sidecar.exists(), "partial sidecar for new chunk must exist"); + let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&chunk2_sidecar).unwrap()).unwrap(); + assert_eq!(v["in_progress"], true); + assert_eq!(v["device_name"], "RollPartial"); + assert_eq!(v["device_kind"], "test"); + assert_eq!(v["total_samples"], 0); + } + + /// `Pipeline::roll` must trigger a background pre-warm of the + /// just-closed chunk's metrics cache so the history page doesn't pay + /// the cold-build cost on first open. + #[test] + fn pipeline_roll_prewarms_metrics_cache() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 4, + 256.0, + vec!["TP9".into(), "AF7".into(), "AF8".into(), "TP10".into()], + "PrewarmDevice".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + pipe.device_kind = "muse".into(); + + // Write enough samples to produce at least a few metrics rows. + for i in 0..1500 { + pipe.push_eeg(&[1.0, 2.0, 3.0, 4.0], i as f64 / 256.0); + } + let chunk1 = pipe.csv_path.clone(); + let metrics_path = chunk1.with_file_name(format!( + "{}_metrics.csv", + chunk1.file_stem().and_then(|s| s.to_str()).unwrap_or("") + )); + let cache_path = chunk1.with_file_name(format!( + "{}_metrics_cache.json", + chunk1.file_stem().and_then(|s| s.to_str()).unwrap_or("") + )); + + pipe.roll(dir.path()).unwrap(); + + // Roll spawns a background thread; poll briefly for the cache + // file to appear. Cap at 2s to keep the test snappy if the + // pre-warm is somehow disabled. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut appeared = false; + while std::time::Instant::now() < deadline { + if cache_path.exists() { + appeared = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + + assert!(metrics_path.exists(), "metrics CSV must exist after roll"); + assert!( + appeared, + "metrics_cache.json must be pre-warmed within 2s of roll: {cache_path:?}" + ); + // Cache content must be valid JSON. + let cache_str = std::fs::read_to_string(&cache_path).unwrap(); + let _: serde_json::Value = serde_json::from_str(&cache_str).expect("cache is valid JSON"); + + pipe.finalize(); + } + + /// Empirical: every sample pushed before `roll` must end up as a row + /// in the closed chunk's CSV. If the on-disk row count is short of + /// what we pushed, that pinpoints the loss as CSV-writer residue at + /// the rollover boundary (the 0.15% the 1-hour test observed). + #[test] + fn pipeline_roll_no_sample_loss_on_csv_boundary() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 4, + 256.0, + vec!["TP9".into(), "AF7".into(), "AF8".into(), "TP10".into()], + "LossDevice".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + pipe.device_kind = "muse".into(); + + // Push exactly 1000 frames (1000 samples × 4 channels) to chunk 1. + const N: usize = 1000; + for i in 0..N { + pipe.push_eeg(&[1.0, 2.0, 3.0, 4.0], i as f64 / 256.0); + } + let chunk1_path = pipe.csv_path.clone(); + let pushed_chunk1 = pipe.total_samples; + assert_eq!(pushed_chunk1, N as u64, "counter must match pushes"); + + // Roll and finalize the new chunk to flush both files cleanly. + pipe.roll(dir.path()).unwrap(); + for i in 0..50 { + pipe.push_eeg(&[5.0, 6.0, 7.0, 8.0], i as f64 / 256.0); + } + pipe.finalize(); + + // Count actual data rows on disk (subtract the header). + let content = std::fs::read_to_string(&chunk1_path).unwrap(); + let data_rows = content.lines().count().saturating_sub(1); + + assert_eq!( + data_rows, N, + "chunk1 CSV must contain every pushed sample (residue-free roll)" + ); + } + + /// Same-second rollover must still produce a unique filename. + #[test] + fn pipeline_roll_handles_subsecond_collision() { + let dir = tempfile::tempdir().unwrap(); + let settings = skill_settings::UserSettings::default(); + let settings_path = skill_settings::settings_path(dir.path()); + if let Some(parent) = settings_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let _ = std::fs::write(&settings_path, serde_json::to_string_pretty(&settings).unwrap()); + + let (tx, _rx) = broadcast::channel(16); + let mut pipe = Pipeline::open( + dir.path(), + 2, + 128.0, + vec!["Ch1".into(), "Ch2".into()], + "Sub".into(), + tx, + Vec::new(), + crate::text_embedder::SharedTextEmbedder::new(), + ) + .unwrap(); + + let p0 = pipe.csv_path.clone(); + pipe.roll(dir.path()).unwrap(); + let p1 = pipe.csv_path.clone(); + pipe.roll(dir.path()).unwrap(); + let p2 = pipe.csv_path.clone(); + + assert_ne!(p0, p1); + assert_ne!(p1, p2); + assert_ne!(p0, p2); + assert!(p0.exists() && p1.exists() && p2.exists()); + } } diff --git a/crates/skill-daemon/src/session/retention.rs b/crates/skill-daemon/src/session/retention.rs new file mode 100644 index 00000000..376b439a --- /dev/null +++ b/crates/skill-daemon/src/session/retention.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Session-file retention. +//! +//! Day directories (`/YYYYMMDD/`) hold all of a day's session +//! artifacts: EEG/PPG/IMU/fNIRS CSV+Parquet, sidecar JSONs, metrics caches, +//! per-day SQLite + HNSW indices. After `file_retention_days` they are +//! removed wholesale. +//! +//! Wholesale day-dir deletion is correct because every session-related +//! artifact lives inside the day dir, and the dir name itself encodes the +//! date — no per-file timestamp parsing required. + +use std::path::Path; + +/// Convert Unix seconds (UTC) to a packed `YYYYMMDD` integer using the same +/// civil-from-days arithmetic as [`crate::session::shared::utc_date_dir`]. +pub(crate) fn unix_to_yyyymmdd(secs: u64) -> u32 { + let days = (secs / 86400) as i64; + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as u32) * 10000 + (m as u32) * 100 + (d as u32) +} + +/// Remove every day directory in `skill_dir` whose name is older than the +/// retention window. Returns `(removed, errors)`. +/// +/// `retention_days == 0` disables retention (matches the convention used +/// elsewhere in settings). +pub(crate) fn prune_session_dirs(skill_dir: &Path, retention_days: u32, now_secs: u64) -> (usize, usize) { + if retention_days == 0 { + return (0, 0); + } + let cutoff_secs = now_secs.saturating_sub(u64::from(retention_days) * 86400); + let cutoff_yyyymmdd = unix_to_yyyymmdd(cutoff_secs); + + let Ok(entries) = std::fs::read_dir(skill_dir) else { + return (0, 0); + }; + + let mut removed = 0; + let mut errors = 0; + for entry in entries.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + if !ft.is_dir() { + continue; + } + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { continue }; + if name_str.len() != 8 || !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let Ok(dir_yyyymmdd) = name_str.parse::() else { + continue; + }; + if dir_yyyymmdd >= cutoff_yyyymmdd { + continue; + } + match std::fs::remove_dir_all(entry.path()) { + Ok(()) => removed += 1, + Err(e) => { + errors += 1; + tracing::warn!(dir = %entry.path().display(), %e, "failed to prune session day dir"); + } + } + } + (removed, errors) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn touch_dir(root: &Path, name: &str) { + let d = root.join(name); + std::fs::create_dir_all(&d).unwrap(); + // Drop a placeholder file so we exercise recursive removal. + std::fs::write(d.join("exg_1.csv"), "timestamp_s,Ch1\n0.0,0.0\n").unwrap(); + std::fs::write(d.join("exg_1.json"), r#"{"device_name":"x"}"#).unwrap(); + } + + #[test] + fn unix_to_yyyymmdd_known_dates() { + // 2024-01-01 00:00:00 UTC = 1704067200 + assert_eq!(unix_to_yyyymmdd(1_704_067_200), 20240101); + // 2025-06-15 12:00:00 UTC = 1750_000_800 — verify packing format. + let v = unix_to_yyyymmdd(1_750_000_800); + assert!((20250101..=20251231).contains(&v)); + // Epoch. + assert_eq!(unix_to_yyyymmdd(0), 19700101); + } + + #[test] + fn prune_removes_old_dirs_only() { + let td = tempfile::tempdir().unwrap(); + let root = td.path(); + // now = 2024-06-15 + let now: u64 = 1_718_409_600; + + // Old: 2024-01-01 (≈ 166 days old) + touch_dir(root, "20240101"); + // Borderline: 2024-06-14 (1 day old — keep) + touch_dir(root, "20240614"); + // Today. + touch_dir(root, "20240615"); + // Non-day dir: must NOT be touched. + touch_dir(root, "logs"); + // 8-digit non-numeric: must NOT be touched. + std::fs::create_dir_all(root.join("notadate")).unwrap(); + std::fs::write(root.join("notadate/marker"), "").unwrap(); + + let (removed, errors) = prune_session_dirs(root, 30, now); + assert_eq!(errors, 0); + assert_eq!(removed, 1, "only the 2024-01-01 dir should be pruned"); + + assert!(!root.join("20240101").exists()); + assert!(root.join("20240614").exists()); + assert!(root.join("20240615").exists()); + assert!(root.join("logs").exists()); + assert!(root.join("notadate").exists()); + } + + #[test] + fn prune_disabled_when_retention_zero() { + let td = tempfile::tempdir().unwrap(); + touch_dir(td.path(), "20200101"); + let now: u64 = 1_718_409_600; + + let (removed, errors) = prune_session_dirs(td.path(), 0, now); + assert_eq!(removed, 0); + assert_eq!(errors, 0); + assert!(td.path().join("20200101").exists()); + } + + #[test] + fn prune_handles_missing_dir() { + let td = tempfile::tempdir().unwrap(); + let missing = td.path().join("does_not_exist"); + let (removed, errors) = prune_session_dirs(&missing, 30, 1_718_409_600); + assert_eq!(removed, 0); + assert_eq!(errors, 0); + } +} diff --git a/crates/skill-daemon/src/session/runner.rs b/crates/skill-daemon/src/session/runner.rs index 4b31c687..d2238345 100644 --- a/crates/skill-daemon/src/session/runner.rs +++ b/crates/skill-daemon/src/session/runner.rs @@ -37,6 +37,29 @@ pub(crate) async fn run_adapter_session( let idle_sleep = tokio::time::sleep(IDLE_TIMEOUT); tokio::pin!(idle_sleep); + // Session rollover: bound the blast radius of a daemon crash to ≤ N + // minutes of data, and keep individual files small enough for readers. + // Configurable via `session_rollover_minutes` (0 = disabled). The + // value is re-read from settings every time the timer fires so the + // user can change the interval (or disable rollover entirely) mid- + // session without restarting the recording. + fn read_rollover_duration(skill_dir: &std::path::Path) -> (u64, std::time::Duration) { + let secs = u64::from(skill_settings::load_settings(skill_dir).session_rollover_minutes).saturating_mul(60); + // When disabled, use a finite-but-effectively-infinite duration. + // The select arm gates on `secs > 0` so the sleep is never read, + // but the Sleep future still exists and its deadline must not + // overflow tokio's internal Instant arithmetic — so cap at ~10y. + let dur = if secs == 0 { + std::time::Duration::from_secs(60 * 60 * 24 * 365 * 10) + } else { + std::time::Duration::from_secs(secs) + }; + (secs, dur) + } + let (mut rollover_secs, mut rollover_duration) = read_rollover_duration(&skill_dir); + let rollover_sleep = tokio::time::sleep(rollover_duration); + tokio::pin!(rollover_sleep); + loop { tokio::select! { biased; @@ -55,6 +78,26 @@ pub(crate) async fn run_adapter_session( broadcast_event(&state.events_tx, "DeviceDisconnected", &serde_json::json!({"reason": "idle_timeout"})); break; } + () = &mut rollover_sleep, if rollover_secs > 0 && pipeline.is_some() => { + // Hot-reload settings BEFORE firing so a user disabling + // rollover (or extending the interval) gets honoured + // immediately — without this re-read, the already-armed + // sleep would still fire one more time after the change. + let (new_secs, new_dur) = read_rollover_duration(&skill_dir); + rollover_secs = new_secs; + rollover_duration = new_dur; + + if rollover_secs > 0 { + if let Some(ref mut pipe) = pipeline { + if let Err(e) = pipe.roll(&skill_dir) { + error!(%e, "session rollover failed; continuing on existing writer"); + } else if let Ok(mut s) = state.status.lock() { + s.csv_path = Some(pipe.csv_path.display().to_string()); + } + } + } + rollover_sleep.as_mut().reset(tokio::time::Instant::now() + rollover_duration); + } ev = adapter.next_event() => { // Reset idle timer on every event. idle_sleep.as_mut().reset(tokio::time::Instant::now() + IDLE_TIMEOUT); @@ -123,10 +166,15 @@ pub(crate) async fn run_adapter_session( p.firmware_version = info.firmware_version.clone(); p.device_kind = device_kind.to_string(); p.fnirs_channel_names = current_desc.fnirs_channel_names.clone(); + // Drop a partial sidecar before any samples + // flow so a crash here leaves a readable + // session entry for list_sessions_for_day. + p.write_partial_sidecar(); if let Ok(mut s) = state.status.lock() { s.csv_path = Some(p.csv_path.display().to_string()); } pipeline = Some(p); + rollover_sleep.as_mut().reset(tokio::time::Instant::now() + rollover_duration); } Err(e) => error!(%e, "pipeline open failed"), } @@ -1900,4 +1948,340 @@ mod tests { .saturating_sub(1); // minus header assert_eq!(csv_rows, expected_imu_frames as usize, "IMU CSV row count mismatch"); } + + // ── Rollover: long-running session produces multiple chunk files ────────── + + fn write_settings_with_rollover(skill_dir: &std::path::Path, minutes: u32) { + let mut s = skill_settings::UserSettings::default(); + s.session_rollover_minutes = minutes; + let p = skill_settings::settings_path(skill_dir); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&p, serde_json::to_string_pretty(&s).unwrap()).unwrap(); + } + + /// Locate the single `YYYYMMDD/` day directory under `root`. + fn find_day_dir(root: &std::path::Path) -> Option { + std::fs::read_dir(root).ok()?.flatten().find_map(|e| { + let p = e.path(); + let name = p.file_name()?.to_str()?.to_string(); + if p.is_dir() && name.len() == 8 && name.chars().all(|c| c.is_ascii_digit()) { + Some(p) + } else { + None + } + }) + } + + /// With `session_rollover_minutes = 1`, a session that runs ≥ 60 fake-time + /// seconds should produce at least two raw EEG CSV chunks. `start_paused` + /// makes tokio auto-advance virtual time across `tokio::time::sleep` calls + /// so the test runs in real-milliseconds. + #[tokio::test(start_paused = true)] + async fn session_rolls_over_after_configured_interval() { + let dir = TempDir::new().unwrap(); + write_settings_with_rollover(dir.path(), 1); + let state = test_state(dir.path()); + + // Each adapter event carries a 2 s fake-time delay → 40 events ≈ 80 s, + // well past the 60 s rollover threshold. + let mut adapter = MockAdapter::new(eeg_desc("muse", 4, 256.0)).with_delay(Duration::from_secs(2)); + adapter.push(DeviceEvent::Connected(DeviceInfo { + name: "Muse-Roll".to_string(), + id: "mock:roll".to_string(), + firmware_version: Some("1.0.0".to_string()), + ..Default::default() + })); + for i in 0..40 { + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + } + adapter.push(DeviceEvent::Disconnected); + + run(state, adapter).await; + + // Count raw EEG chunk CSVs (excluding suffixed companions). + let chunks: Vec<_> = files_recursive(dir.path()) + .into_iter() + .filter(|p| { + let s = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + s.starts_with("exg_") + && s.ends_with(".csv") + && !s.contains("_imu") + && !s.contains("_ppg") + && !s.contains("_metrics") + && !s.contains("_fnirs") + }) + .collect(); + + assert!( + chunks.len() >= 2, + "expected ≥2 EEG chunk CSVs after rollover, got {}: {:?}", + chunks.len(), + chunks + ); + + // Each chunk has its own sidecar JSON, both readable and well-formed. + for chunk in &chunks { + let sidecar = chunk.with_extension("json"); + assert!(sidecar.exists(), "missing sidecar for {chunk:?}"); + let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap(); + assert_eq!(v["device_name"], "Muse-Roll"); + } + + // ── E2E: real consumer code paths must accept rolled-over chunks ── + // + // Build env can't link the bin (llama-cpp-sys-4 native lib mismatch), + // so instead of going over HTTP we drive the same in-process Rust + // readers the HTTP routes call. This is the layer where rollover + // could break behaviour — HTTP is just transport. + + let day_dir = find_day_dir(dir.path()).expect("session day dir created"); + let day = day_dir.file_name().unwrap().to_str().unwrap().to_string(); + + // 1. History listing — used by /v1/history/sessions and the frontend + // history page. Same-device adjacent chunks are now collapsed + // into one logical entry whose `chunk_count` reflects the roll + // count. With this test pushing N chunks of "Muse-Roll", expect + // a single merged entry covering all of them. + let entries = skill_history::list_sessions_for_day(&day, dir.path(), None); + assert_eq!( + entries.len(), + 1, + "{} same-device chunks must collapse into 1 logical entry", + chunks.len() + ); + let merged = &entries[0]; + assert_eq!( + u32::try_from(chunks.len()).unwrap(), + merged.chunk_count, + "merged entry's chunk_count must match the on-disk chunk count" + ); + assert_eq!(merged.device_name.as_deref(), Some("Muse-Roll")); + assert!( + std::path::Path::new(&merged.csv_path).exists(), + "canonical csv_path resolves: {}", + merged.csv_path + ); + assert!(merged.session_start_utc.is_some(), "session_start_utc populated"); + assert!( + merged.firmware_version.as_deref() == Some("1.0.0"), + "firmware_version threaded through to merged entry" + ); + let chunk_paths = merged.chunks.as_ref().expect("chunks list present on merged entry"); + assert_eq!(chunk_paths.len(), chunks.len(), "every chunk path preserved"); + for p in chunk_paths { + assert!(std::path::Path::new(p).exists(), "every chunk path resolves: {p}"); + } + + // 2. Per-day SQLite (search index) is created for the day, not per + // session — so embeddings written by a chunk land in the same + // file as embeddings from sibling chunks. Embedding is skipped + // in #[cfg(test)], but the file should still get opened by + // EpochStore on first epoch flush. + let sqlite_path = day_dir.join(skill_constants::SQLITE_FILE); + if sqlite_path.exists() { + // Day SQLite is a single file across chunks — the rollover + // does NOT create a new SQLite. Verify by counting hits and + // confirming there is exactly one file. + let count = std::fs::read_dir(&day_dir) + .unwrap() + .flatten() + .filter(|e| e.file_name() == sqlite_path.file_name().unwrap()) + .count(); + assert_eq!(count, 1, "exactly one per-day SQLite, regardless of chunks"); + } + } + + /// With `session_rollover_minutes = 0`, rollover is disabled — even a + /// long-running session must produce exactly one chunk. + #[tokio::test(start_paused = true)] + async fn rollover_disabled_keeps_single_chunk() { + let dir = TempDir::new().unwrap(); + write_settings_with_rollover(dir.path(), 0); + let state = test_state(dir.path()); + + let mut adapter = MockAdapter::new(eeg_desc("muse", 4, 256.0)).with_delay(Duration::from_secs(2)); + adapter.push(DeviceEvent::Connected(DeviceInfo { + name: "Muse-NoRoll".to_string(), + id: "mock:noroll".to_string(), + ..Default::default() + })); + for i in 0..40 { + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + } + adapter.push(DeviceEvent::Disconnected); + + run(state, adapter).await; + + let chunks: Vec<_> = files_recursive(dir.path()) + .into_iter() + .filter(|p| { + let s = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + s.starts_with("exg_") + && s.ends_with(".csv") + && !s.contains("_imu") + && !s.contains("_ppg") + && !s.contains("_metrics") + && !s.contains("_fnirs") + }) + .collect(); + + assert_eq!( + chunks.len(), + 1, + "expected exactly 1 chunk with rollover disabled, got {chunks:?}" + ); + } + + /// A `Disconnected` event arriving exactly at the rollover boundary + /// must not panic, deadlock, or leave the daemon in a stuck state. + /// The biased `select!` order is `cancel > idle > rollover > event`, + /// so when the rollover_sleep and the next adapter event are both + /// ready in the same poll, rollover fires first and the disconnect + /// is processed on the next iteration. Either ordering must produce + /// a clean shutdown with the existing chunk(s) finalised. + #[tokio::test(start_paused = true)] + async fn rollover_and_disconnect_race_clean_shutdown() { + let dir = TempDir::new().unwrap(); + write_settings_with_rollover(dir.path(), 1); + let state = test_state(dir.path()); + + // Adapter pushes events at exactly 1s/event so by event #60 we + // are at fake-time 60s, the rollover boundary. The 60th event + // is `Disconnected` — racing with rollover_sleep's fire. + let mut adapter = MockAdapter::new(eeg_desc("muse", 4, 256.0)).with_delay(Duration::from_secs(1)); + adapter.push(DeviceEvent::Connected(DeviceInfo { + name: "Muse-Race".to_string(), + id: "mock:race".to_string(), + ..Default::default() + })); + for i in 0..58 { + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + } + // Event 59 brings us to fake-time ≈ 60s, the rollover instant. + adapter.push(DeviceEvent::Disconnected); + + let state_check = state.clone(); + run(state, adapter).await; + + // Daemon must end up disconnected, not stuck. + assert_eq!(state_check.status.lock().unwrap().state, "disconnected"); + + // At least one chunk must exist and have a sidecar — proves the + // pipeline finalised cleanly even under the race. + let chunks: Vec<_> = files_recursive(dir.path()) + .into_iter() + .filter(|p| { + let s = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + s.starts_with("exg_") + && s.ends_with(".csv") + && !s.contains("_imu") + && !s.contains("_ppg") + && !s.contains("_metrics") + && !s.contains("_fnirs") + }) + .collect(); + assert!(!chunks.is_empty(), "at least one chunk written before disconnect"); + for c in &chunks { + let sidecar = c.with_extension("json"); + assert!( + sidecar.exists(), + "every chunk must have a sidecar (partial or full): {c:?}" + ); + } + } + + /// Mid-session, the user disables rollover by writing + /// `session_rollover_minutes = 0` to settings.json. The first roll + /// (already armed) fires on schedule, then re-reads settings and + /// stops scheduling further rolls. So a long run produces exactly + /// 2 chunks: the initial one + the one from the first roll, with + /// no further rolls after the setting flips to 0. + #[tokio::test(start_paused = true)] + async fn rollover_setting_hot_reloads_to_disabled() { + let dir = TempDir::new().unwrap(); + write_settings_with_rollover(dir.path(), 1); + let state = test_state(dir.path()); + + // Adapter pushes an event every 2 fake-seconds for ~5 minutes + // of fake time (well past 2 rollover boundaries at minutes=1). + let mut adapter = MockAdapter::new(eeg_desc("muse", 4, 256.0)).with_delay(Duration::from_secs(2)); + adapter.push(DeviceEvent::Connected(DeviceInfo { + name: "Muse-HotReload".to_string(), + id: "mock:hotreload".to_string(), + firmware_version: Some("hr-1".to_string()), + ..Default::default() + })); + // Half of the events use rollover=1min; we'll switch to 0 + // (disabled) once the runner has had a chance to do its first + // roll. At 2s/event, ~30 events ≈ 60s = first roll boundary. + for i in 0..150 { + // After ~70s of fake time (35 events), flip the setting. + if i == 35 { + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + // Stash a marker event — we'll inject the settings + // flip in a parallel task because the adapter queue + // is drained inside the daemon. + continue; + } + adapter.push(DeviceEvent::Eeg(EegFrame { + channels: vec![1.0, 2.0, 3.0, 4.0], + timestamp_s: i as f64 / 256.0, + })); + } + adapter.push(DeviceEvent::Disconnected); + + // Spawn a parallel task that flips the rollover setting to 0 + // after enough fake time has elapsed for one roll to have + // happened. Real-time sleep here is a small grace period; the + // tokio runtime auto-advances fake time across the adapter's + // 2s sleeps so this fires after the first roll. + let dir_path = dir.path().to_path_buf(); + let flipper = tokio::spawn(async move { + // Wait for ~70s of fake time. The settings flip is on the + // real filesystem so it doesn't depend on tokio's clock. + tokio::time::sleep(Duration::from_secs(70)).await; + write_settings_with_rollover(&dir_path, 0); + }); + + run(state, adapter).await; + let _ = flipper.await; + + let chunks: Vec<_> = files_recursive(dir.path()) + .into_iter() + .filter(|p| { + let s = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + s.starts_with("exg_") + && s.ends_with(".csv") + && !s.contains("_imu") + && !s.contains("_ppg") + && !s.contains("_metrics") + && !s.contains("_fnirs") + }) + .collect(); + + // With hot-reload disabled, we'd see 4–5 chunks (one per minute + // of fake time). With hot-reload working, we see exactly 2: + // the initial chunk + the one created at t=60s (first roll). + // After t=70s the setting flips to 0; no further rolls fire. + assert_eq!( + chunks.len(), + 2, + "hot-reload to disabled must stop rollover after the next fire, got {} chunks", + chunks.len() + ); + } } diff --git a/crates/skill-daemon/src/session/shared.rs b/crates/skill-daemon/src/session/shared.rs index e33cf9d2..280da1e9 100644 --- a/crates/skill-daemon/src/session/shared.rs +++ b/crates/skill-daemon/src/session/shared.rs @@ -66,14 +66,17 @@ pub fn broadcast_event(tx: &broadcast::Sender, event_type: &str, // ── Band snapshot enrichment ────────────────────────────────────────────────── -/// Enrich a `BandSnapshot` with composite scores (focus, relaxation, engagement, -/// artifacts) and return the result as JSON. +/// Enrich a `BandSnapshot` with composite scores and return the result as JSON. +/// +/// All composite-score math (engagement / relaxation / focus / meditation / +/// cognitive_load / drowsiness) lives in `skill_devices` and is written +/// directly onto the snapshot fields by `skill_devices::enrich_band_snapshot`. +/// This wrapper only adds the daemon-side context (artifacts, GPU stats) and +/// serializes — every consumer reads identical values from the snapshot. pub fn enrich_band_snapshot( snap: &mut skill_eeg::eeg_bands::BandSnapshot, artifacts: Option<&skill_eeg::artifact_detection::ArtifactMetrics>, ) -> serde_json::Value { - // Use skill_devices::enrich_band_snapshot for the full enrichment - // (blink_count, blink_rate, head_pose, composite scores). let ctx = skill_devices::SnapshotContext { ppg: None, artifacts: artifacts.copied(), @@ -82,26 +85,7 @@ pub fn enrich_band_snapshot( gpu: skill_data::gpu_stats::read(), }; skill_devices::enrich_band_snapshot(snap, &ctx); - - // Add composite scores derived from band power. - let mut val = serde_json::to_value(&*snap).unwrap_or_default(); - if let Some(obj) = val.as_object_mut() { - let engage_raw = skill_devices::compute_engagement_raw(snap); - let focus = skill_devices::focus_score(engage_raw); - let nch = snap.channels.len().max(1) as f64; - let avg_alpha = snap.channels.iter().map(|c| c.rel_alpha as f64).sum::() / nch; - let avg_beta = snap.channels.iter().map(|c| c.rel_beta as f64).sum::() / nch; - let relaxation = if (avg_alpha + avg_beta) > 0.0 { - (avg_alpha / (avg_alpha + avg_beta)) * 100.0 - } else { - 0.0 - }; - let engagement = 100.0 / (1.0 + (-2.0 * (engage_raw as f64 - 0.8)).exp()); - obj.insert("focus".into(), serde_json::json!(focus)); - obj.insert("relaxation".into(), serde_json::json!(relaxation)); - obj.insert("engagement".into(), serde_json::json!(engagement)); - } - val + serde_json::to_value(&*snap).unwrap_or_default() } // ── Session metadata ────────────────────────────────────────────────────────── @@ -198,3 +182,42 @@ pub fn write_session_meta_full( let _ = std::fs::write(csv_path.with_extension("json"), json); } } + +/// Write a minimal in-progress sidecar JSON immediately after opening a +/// recording, before any samples are flushed. Crash-resilience: a daemon +/// killed mid-chunk leaves a sidecar with the known device/channel/rate +/// fields, so `list_sessions_for_day` doesn't have to fall back to +/// CSV-header sniffing for partial recordings. +/// +/// Carries an `in_progress: true` marker; the full writer overwrites this +/// file on `finalize()` and that flag is dropped. +pub fn write_session_meta_partial( + csv_path: &Path, + device_name: &str, + channel_names: &[String], + sample_rate: f64, + start_utc: u64, + device_id: &SessionDeviceId<'_>, + device_kind: &str, +) { + let meta = serde_json::json!({ + "csv_file": csv_path.file_name().and_then(|n| n.to_str()).unwrap_or(""), + "session_start_utc": start_utc, + "total_samples": 0, + "sample_rate_hz": sample_rate, + "device_name": device_name, + "device_kind": device_kind, + "channel_names": channel_names, + "channel_count": channel_names.len(), + "firmware_version": device_id.firmware_version, + "serial_number": device_id.serial_number, + "daemon": true, + "in_progress": true, + "platform": std::env::consts::OS, + "arch": std::env::consts::ARCH, + }); + + if let Ok(json) = serde_json::to_string_pretty(&meta) { + let _ = std::fs::write(csv_path.with_extension("json"), json); + } +} diff --git a/crates/skill-daemon/src/tty.rs b/crates/skill-daemon/src/tty.rs index 5a89c6ab..b6794bdc 100644 --- a/crates/skill-daemon/src/tty.rs +++ b/crates/skill-daemon/src/tty.rs @@ -347,36 +347,26 @@ fn default_log_path() -> anyhow::Result { Ok(dir.join(format!("{ts}-{pid}.log"))) } -/// Compress finished logs (whose PID is no longer alive) to `.log.zst`, -/// then enforce a 100-file retention cap on the combined `.log`/`.log.zst` -/// set. Skips `current_log` so we never touch the file we're about to write. +/// Enforce a 100-file retention cap on terminal scratch logs. +/// +/// Keep this deliberately lightweight: this fallback path can also remain +/// alive for an entire shell session, so heavy compression belongs in the +/// daemon finalizer rather than the PTY shim itself. fn rotate_logs(dir: Option<&std::path::Path>, current_log: &std::path::Path) { let Some(dir) = dir else { return }; - // Phase 1: compress every uncompressed log whose owning PID has exited. - let Ok(entries) = std::fs::read_dir(dir) else { return }; - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path == current_log { - continue; - } - if path.extension().is_none_or(|e| e != "log") { - continue; - } - if pid_alive_for_log(&path) { - continue; // another shim is still appending - } - let _ = compress_to_zst(&path); - } - - // Phase 2: enforce retention. Compressed logs are tiny so we can keep - // many more than the old uncompressed cap. let Ok(entries) = std::fs::read_dir(dir) else { return }; let mut all: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries .filter_map(|e| e.ok()) .filter(|e| { - e.path() - .extension() + let path = e.path(); + if path == current_log { + return false; + } + if path.extension().is_some_and(|e| e == "log") && pid_alive_for_log(&path) { + return false; + } + path.extension() .and_then(|s| s.to_str()) .is_some_and(|ext| ext == "log" || ext == "zst") }) @@ -407,18 +397,3 @@ fn pid_alive_for_log(path: &std::path::Path) -> bool { // exists and we have permission). Errno ESRCH means it's gone. unsafe { libc::kill(pid, 0) == 0 } } - -/// Stream-compress `src` into a sibling `.zst`, then delete `src`. -/// Compression level 3 (zstd default) is fast on CPU and still yields ~10× -/// reduction for ANSI-heavy terminal output. -fn compress_to_zst(src: &std::path::Path) -> std::io::Result<()> { - let dst = src.with_extension("log.zst"); - let input = std::fs::File::open(src)?; - let output = std::fs::File::create(&dst)?; - let mut encoder = zstd::Encoder::new(output, 3)?; - let mut reader = std::io::BufReader::new(input); - std::io::copy(&mut reader, &mut encoder)?; - encoder.finish()?; - std::fs::remove_file(src)?; - Ok(()) -} diff --git a/crates/skill-daemon/src/tty_embedder.rs b/crates/skill-daemon/src/tty_embedder.rs index ec6d0a2e..bbe1fa35 100644 --- a/crates/skill-daemon/src/tty_embedder.rs +++ b/crates/skill-daemon/src/tty_embedder.rs @@ -25,8 +25,10 @@ pub fn spawn(state: AppState) { tokio::spawn(async move { loop { tokio::time::sleep(TICK).await; + let tick_start = std::time::Instant::now(); let s = state.clone(); let _ = tokio::task::spawn_blocking(move || run_once(&s)).await; + state.record_task_heartbeat("tty-embedder", tick_start.elapsed().as_millis() as u64); } }); } diff --git a/crates/skill-daemon/src/tty_finalizer.rs b/crates/skill-daemon/src/tty_finalizer.rs index 4a43930d..11eb2e51 100644 --- a/crates/skill-daemon/src/tty_finalizer.rs +++ b/crates/skill-daemon/src/tty_finalizer.rs @@ -16,6 +16,7 @@ //! compression, ANSI strip) lives on `spawn_blocking` so it doesn't stall //! the runtime. +use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -78,7 +79,7 @@ fn finalize_one(state: &AppState, log_path: &Path) -> anyhow::Result<()> { return Ok(()); } - let log_bytes = std::fs::read(log_path)?; + let log_len = std::fs::metadata(log_path)?.len(); let session_start_us = idx.first().map(|e| e.micros).unwrap_or(0); let session_end_us = idx.last().map(|e| e.micros).unwrap_or(u64::MAX); // `terminal_commands.started_at`/`ended_at` are in seconds; convert to @@ -114,14 +115,14 @@ fn finalize_one(state: &AppState, log_path: &Path) -> anyhow::Result<()> { if end_off <= start_off { continue; } - let end_off = end_off.min(log_bytes.len() as u64); + let end_off = end_off.min(log_len); let start_off = start_off.min(end_off); let raw_size = end_off - start_off; let raw_capped = raw_size.min(MAX_RAW_BYTES_PER_COMMAND); - let raw_slice = &log_bytes[start_off as usize..(start_off + raw_capped) as usize]; + let raw_slice = read_log_slice(log_path, start_off, raw_capped)?; // Compute stripped text from the (possibly truncated) raw slice. - let mut stripped = skill_data::ansi::strip_ansi(raw_slice); + let mut stripped = skill_data::ansi::strip_ansi(&raw_slice); if stripped.len() > MAX_STRIPPED_CHARS_PER_COMMAND { // Truncate at a UTF-8 char boundary. let mut end = MAX_STRIPPED_CHARS_PER_COMMAND; @@ -133,7 +134,7 @@ fn finalize_one(state: &AppState, log_path: &Path) -> anyhow::Result<()> { // Compress the raw slice. Level 3 is the zstd default — fast and good // ratio on highly-repetitive ANSI streams. - let raw_zstd = match zstd::encode_all(raw_slice, 3) { + let raw_zstd = match zstd::encode_all(&raw_slice[..], 3) { Ok(v) => Some(v), Err(e) => { warn!(error = %e, "zstd encode failed; storing stripped text only"); @@ -155,7 +156,7 @@ fn finalize_one(state: &AppState, log_path: &Path) -> anyhow::Result<()> { debug!( path = %log_path.display(), commands = written, - bytes = log_bytes.len(), + bytes = log_len, "finalized session" ); @@ -165,6 +166,15 @@ fn finalize_one(state: &AppState, log_path: &Path) -> anyhow::Result<()> { Ok(()) } +fn read_log_slice(path: &Path, start: u64, len: u64) -> anyhow::Result> { + let mut file = std::fs::File::open(path)?; + file.seek(SeekFrom::Start(start))?; + + let mut out = vec![0u8; len as usize]; + file.read_exact(&mut out)?; + Ok(out) +} + #[derive(Clone, Copy)] struct IdxEntry { /// Byte offset *after* this PTY-write batch. diff --git a/crates/skill-daemon/src/util.rs b/crates/skill-daemon/src/util.rs index ae6eb60e..d7bb060a 100644 --- a/crates/skill-daemon/src/util.rs +++ b/crates/skill-daemon/src/util.rs @@ -3,6 +3,44 @@ pub(crate) use skill_daemon_state::util::*; use crate::state::AppState; +/// Locate the sibling `skill-tty` binary relative to the running daemon. +/// +/// Layouts we have to handle: +/// 1. Dev / Linux deb-rpm / portable Linux / Windows: flat sibling +/// /skill-tty[.exe] +/// 2. macOS production .app: each binary lives in its own .app bundle +/// under the outer app's MacOS dir, e.g. +/// /MacOS/skill-daemon.app/Contents/MacOS/skill-daemon +/// /MacOS/skill-tty.app/Contents/MacOS/skill-tty +/// so we walk up four parents from the daemon binary to reach +/// `/MacOS/`, then descend into `skill-tty.app`. +/// +/// Returns `None` if no sibling can be found (older builds without skill-tty); +/// callers should fall back to the in-process PTY shim in that case. +pub(crate) fn resolve_skill_tty_path() -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + + // Flat sibling (dev, Linux, Windows). + for name in ["skill-tty", "skill-tty.exe"] { + let cand = dir.join(name); + if cand.is_file() { + return Some(cand); + } + } + + // macOS .app sibling: dir is .../skill-daemon.app/Contents/MacOS/, walk + // up to the outer MacOS dir that holds both .app wrappers. + if let Some(outer_macos) = dir.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) { + let cand = outer_macos.join("skill-tty.app/Contents/MacOS/skill-tty"); + if cand.is_file() { + return Some(cand); + } + } + + None +} + /// Spawn the appropriate session runner for the given target device. /// Cancels any existing session first. pub(crate) fn spawn_session_for_target(state: &AppState, target: Option<&str>) { diff --git a/crates/skill-data/src/lib.rs b/crates/skill-data/src/lib.rs index bddf02af..8fb37226 100644 --- a/crates/skill-data/src/lib.rs +++ b/crates/skill-data/src/lib.rs @@ -39,3 +39,6 @@ pub mod util; pub mod validation_store; pub use error::{SessionError, StoreError}; +// Timestamp utilities re-exported for convenience — prefer these over +// hand-rolling `ts * 1000` arithmetic at call sites. +pub use util::{epoch_ts_to_unix, unix_to_ts, yyyymmddhhmmss_utc, DualTimestampRange}; diff --git a/crates/skill-data/src/session_csv.rs b/crates/skill-data/src/session_csv.rs index 7638ca06..033736fa 100644 --- a/crates/skill-data/src/session_csv.rs +++ b/crates/skill-data/src/session_csv.rs @@ -67,7 +67,7 @@ pub fn fnirs_csv_path(eeg_path: &Path) -> PathBuf { /// - 3 GPU utilisation /// /// Cross-channel metric column names (after the per-channel band powers). -pub const METRICS_CROSS_CHANNEL_HEADER: [&str; 46] = [ +pub const METRICS_CROSS_CHANNEL_HEADER: [&str; 47] = [ // ── Cross-channel EEG indices ── "faa", "tar", @@ -121,6 +121,10 @@ pub const METRICS_CROSS_CHANNEL_HEADER: [&str; 46] = [ "gpu_overall_pct", "gpu_render_pct", "gpu_tiler_pct", + // ── Phase metrics ── + // Appended at the end so older recordings (without this column) remain + // readable with the existing fixed-offset parser in skill-history. + "echt", ]; /// Band-power suffixes for each channel (6 absolute + 6 relative = 12 per channel). @@ -158,7 +162,7 @@ pub fn build_metrics_header(channel_names: &[&str]) -> Vec { } /// Legacy fixed header for 4-channel Muse (kept for backward-compat reading). -pub const METRICS_CSV_HEADER: [&str; 95] = [ +pub const METRICS_CSV_HEADER: [&str; 96] = [ "timestamp_s", "TP9_delta", "TP9_theta", @@ -254,6 +258,7 @@ pub const METRICS_CSV_HEADER: [&str; 95] = [ "gpu_overall_pct", "gpu_render_pct", "gpu_tiler_pct", + "echt", ]; // ── CSV writer ──────────────────────────────────────────────────────────────── @@ -658,6 +663,9 @@ impl CsvState { row.push(opt_f64(snap.gpu_render)); row.push(opt_f64(snap.gpu_tiler)); + // Phase metrics (appended at end for backward-compat). + row.push(format!("{:.6}", snap.echt)); + let refs: Vec<&str> = row.iter().map(String::as_str).collect(); let _ = wtr.write_record(&refs); self.metrics_written += 1; @@ -766,8 +774,10 @@ mod round_trip_tests { fn build_metrics_header_has_correct_length() { let channels = ["TP9", "AF7", "AF8", "TP10"]; let header = build_metrics_header(&channels); - // 1 timestamp + 4 channels × 12 bands + 46 cross-channel - assert_eq!(header.len(), 1 + 4 * 12 + 46); + // 1 timestamp + 4 channels × 12 bands + cross-channel indices. + // Track METRICS_CROSS_CHANNEL_HEADER's length so this test doesn't + // need updating each time a new cross-channel metric is added. + assert_eq!(header.len(), 1 + 4 * 12 + METRICS_CROSS_CHANNEL_HEADER.len()); } #[test] @@ -787,7 +797,7 @@ mod round_trip_tests { #[test] fn build_metrics_header_empty_channels() { let header = build_metrics_header(&[]); - assert_eq!(header.len(), 1 + 46); // just timestamp + cross-channel + assert_eq!(header.len(), 1 + METRICS_CROSS_CHANNEL_HEADER.len()); // just timestamp + cross-channel assert_eq!(header[0], "timestamp_s"); } diff --git a/crates/skill-data/src/session_parquet.rs b/crates/skill-data/src/session_parquet.rs index 375a4dee..9cbfeb15 100644 --- a/crates/skill-data/src/session_parquet.rs +++ b/crates/skill-data/src/session_parquet.rs @@ -52,7 +52,7 @@ fn writer_props() -> WriterProperties { /// and `flush` in the same way. pub struct ParquetState { // ── EEG ────────────────────────────────────────────────────────────────── - eeg_wtr: ArrowWriter, + eeg_wtr: Option>, eeg_schema: Arc, n_eeg: usize, eeg_ts: Vec>, @@ -151,7 +151,7 @@ impl ParquetState { let imu_schema = Arc::new(Schema::new(imu_fields)); Ok(Self { - eeg_wtr, + eeg_wtr: Some(eeg_wtr), eeg_schema, n_eeg: n, eeg_ts: (0..n).map(|_| VecDeque::new()).collect(), @@ -226,12 +226,16 @@ impl ParquetState { } if let Ok(batch) = RecordBatch::try_new(self.eeg_schema.clone(), columns) { - let _ = self.eeg_wtr.write(&batch); + if let Some(ref mut w) = self.eeg_wtr { + let _ = w.write(&batch); + } self.eeg_rows += ready; } if self.eeg_rows >= EEG_FLUSH_ROWS { - let _ = self.eeg_wtr.flush(); + if let Some(ref mut w) = self.eeg_wtr { + let _ = w.flush(); + } self.eeg_rows = 0; } } @@ -424,6 +428,7 @@ impl ParquetState { row.extend_from_slice(&[opt(snap.meditation), opt(snap.cognitive_load), opt(snap.drowsiness)]); row.push(opt_u16(snap.temperature_raw)); row.extend_from_slice(&[opt(snap.gpu_overall), opt(snap.gpu_render), opt(snap.gpu_tiler)]); + row.push(snap.echt as f64); self.metrics_pending.push(row); self.metrics_rows += 1; @@ -609,7 +614,9 @@ impl ParquetState { } pub fn flush(&mut self) { - let _ = self.eeg_wtr.flush(); + if let Some(ref mut w) = self.eeg_wtr { + let _ = w.flush(); + } if let Some(ref mut w) = self.ppg_wtr { let _ = w.flush(); } @@ -618,27 +625,116 @@ impl ParquetState { self.flush_fnirs(); } - /// Close all writers, finalising the Parquet files. - pub fn close(mut self) { + /// Close all writers, finalising the Parquet files. Idempotent. + /// + /// A Parquet file is invalid until its footer is written by `close()`. + /// This is also called from `Drop`, so a daemon panic or unexpected + /// shutdown won't leave a footerless file. + pub fn close(&mut self) { self.flush_metrics(); self.flush_imu(); self.flush_fnirs(); - let _ = self.eeg_wtr.close(); - if let Some(w) = self.ppg_wtr { + if let Some(w) = self.eeg_wtr.take() { + let _ = w.close(); + } + if let Some(w) = self.ppg_wtr.take() { let _ = w.close(); } - if let Some(w) = self.metrics_wtr { + if let Some(w) = self.metrics_wtr.take() { let _ = w.close(); } - if let Some(w) = self.imu_wtr { + if let Some(w) = self.imu_wtr.take() { let _ = w.close(); } - if let Some(w) = self.fnirs_wtr { + if let Some(w) = self.fnirs_wtr.take() { let _ = w.close(); } } } +impl Drop for ParquetState { + fn drop(&mut self) { + self.close(); + } +} + +/// Read just the channel names from an EEG parquet schema (cheap — does not +/// scan row data). Returns the column names after the leading `timestamp_s`. +pub fn read_eeg_parquet_channels(path: &Path) -> anyhow::Result> { + use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; + let file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?; + let builder = ParquetRecordBatchReaderBuilder::try_new(file).context("parquet builder")?; + let schema = builder.schema(); + if schema.fields().len() < 2 { + anyhow::bail!("parquet schema has <2 columns"); + } + Ok(schema.fields().iter().skip(1).map(|f| f.name().clone()).collect()) +} + +/// Read a recorded EEG parquet file written by `ParquetState`. +/// +/// Returns `(first_timestamp_secs, channel_names, samples_per_channel)`. +/// Channel order matches `channel_names`. Rows with a null timestamp are +/// skipped. Nulls in channel columns become `0.0` (matching how CSV blanks +/// would be handled by `extract_epoch_samples`'s row-skip path). +/// +/// Used by batch/idle reembed so users on `storage_format = "parquet"` get +/// coverage equivalent to CSV — without this, parquet-only days would be +/// silently skipped (no CSV → `find_eeg_csvs` empty → epochs stay un-embedded +/// forever, looking from the outside like reembed was broken). +pub fn read_eeg_parquet(path: &Path) -> anyhow::Result<(f64, Vec, Vec>)> { + use arrow_array::Array; + use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; + + let file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?; + let builder = ParquetRecordBatchReaderBuilder::try_new(file).context("parquet builder")?; + let schema = builder.schema().clone(); + + if schema.fields().len() < 2 { + anyhow::bail!("parquet schema has <2 columns (need timestamp + ≥1 channel)"); + } + // Column 0 is timestamp_s; the rest are channel columns. + let channel_names: Vec = schema.fields().iter().skip(1).map(|f| f.name().clone()).collect(); + let n_ch = channel_names.len(); + + let reader = builder.build().context("parquet reader")?; + let mut first_ts: Option = None; + let mut channels: Vec> = vec![Vec::new(); n_ch]; + + for batch in reader { + let batch = batch.context("parquet batch")?; + let ts_col = batch + .column(0) + .as_any() + .downcast_ref::() + .ok_or_else(|| anyhow::anyhow!("timestamp_s column is not Float64"))?; + let mut ch_cols: Vec<&arrow_array::Float64Array> = Vec::with_capacity(n_ch); + for k in 0..n_ch { + let col = batch + .column(k + 1) + .as_any() + .downcast_ref::() + .ok_or_else(|| anyhow::anyhow!("channel column {} is not Float64", k + 1))?; + ch_cols.push(col); + } + for i in 0..ts_col.len() { + if ts_col.is_null(i) { + continue; + } + if first_ts.is_none() { + first_ts = Some(ts_col.value(i)); + } + for (k, col) in ch_cols.iter().enumerate() { + let v = if col.is_null(i) { 0.0 } else { col.value(i) as f32 }; + channels[k].push(v); + } + } + } + + let first = first_ts.ok_or_else(|| anyhow::anyhow!("parquet file has no rows"))?; + Ok((first, channel_names, channels)) +} + #[cfg(test)] mod tests { use super::*; @@ -712,6 +808,63 @@ mod tests { assert!(ppg_path.exists(), "Parquet PPG file should exist"); } + #[test] + fn parquet_readable_after_drop_without_explicit_close() { + use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; + + let dir = tempfile::tempdir().unwrap(); + let csv_path = dir.path().join("exg_drop.csv"); + let labels = ["TP9", "AF7", "AF8", "TP10"]; + + let pq_path = eeg_parquet_path(&csv_path); + let ppg_path = ppg_parquet_path(&csv_path); + + { + let mut pq = ParquetState::open_with_labels(&csv_path, &labels).unwrap(); + for i in 0..10 { + let s = [i as f64; 4]; + pq.push_eeg(0, &s, 1000.0 + i as f64, 256.0); + pq.push_eeg(1, &s, 1000.0 + i as f64, 256.0); + pq.push_eeg(2, &s, 1000.0 + i as f64, 256.0); + pq.push_eeg(3, &s, 1000.0 + i as f64, 256.0); + } + // Force PPG writer creation so Drop must close it too. + let ppg = [500.0, 501.0]; + pq.push_ppg(&csv_path, 0, &ppg, 1000.0, None); + pq.push_ppg(&csv_path, 1, &ppg, 1000.0, None); + pq.push_ppg(&csv_path, 2, &ppg, 1000.0, None); + // Intentionally drop without calling close() — Drop must finalise. + } + + let f = std::fs::File::open(&pq_path).expect("parquet exists"); + let builder = ParquetRecordBatchReaderBuilder::try_new(f).expect("eeg footer readable"); + let reader = builder.build().unwrap(); + let total_rows: usize = reader.flatten().map(|b| b.num_rows()).sum(); + assert!(total_rows > 0, "should have read EEG rows back"); + + let f = std::fs::File::open(&ppg_path).expect("ppg parquet exists"); + let builder = ParquetRecordBatchReaderBuilder::try_new(f).expect("ppg footer readable"); + let _ = builder.build().unwrap(); + } + + #[test] + fn parquet_close_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let csv_path = dir.path().join("exg_close.csv"); + let labels = ["TP9", "AF7", "AF8", "TP10"]; + + let mut pq = ParquetState::open_with_labels(&csv_path, &labels).unwrap(); + let s = [1.0; 4]; + pq.push_eeg(0, &s, 1000.0, 256.0); + pq.push_eeg(1, &s, 1000.0, 256.0); + pq.push_eeg(2, &s, 1000.0, 256.0); + pq.push_eeg(3, &s, 1000.0, 256.0); + + pq.close(); + pq.close(); + // Drop runs another close — must not panic. + } + #[test] fn parquet_imu_creates_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/skill-data/src/session_writer.rs b/crates/skill-data/src/session_writer.rs index 81c73d48..bc8d01bf 100644 --- a/crates/skill-data/src/session_writer.rs +++ b/crates/skill-data/src/session_writer.rs @@ -124,6 +124,19 @@ impl SessionWriter { pub fn flush(&mut self) { dispatch!(self, flush()); } + + /// Finalise underlying writers (writes Parquet footer; no-op for CSV). + /// Idempotent. Drop also calls this for crash safety, but calling it + /// explicitly gives deterministic ordering and surfaces errors via logs. + pub fn close(&mut self) { + match self { + Self::Csv(_) => {} + #[cfg(feature = "parquet")] + Self::Parquet(p) => p.close(), + #[cfg(feature = "parquet")] + Self::Both(_, p) => p.close(), + } + } } #[cfg(test)] diff --git a/crates/skill-data/tests/session_csv_roundtrip_tests.rs b/crates/skill-data/tests/session_csv_roundtrip_tests.rs index b8371661..47ef139f 100644 --- a/crates/skill-data/tests/session_csv_roundtrip_tests.rs +++ b/crates/skill-data/tests/session_csv_roundtrip_tests.rs @@ -2,17 +2,19 @@ //! Tests for CsvState: write EEG/PPG/metrics and verify the output. #![allow(clippy::unwrap_used)] -use skill_data::session_csv::{build_metrics_header, CsvState}; +use skill_data::session_csv::{build_metrics_header, CsvState, METRICS_CROSS_CHANNEL_HEADER}; use std::path::Path; use tempfile::tempdir; // ── build_metrics_header ───────────────────────────────────────────────────── +// +// Tests track METRICS_CROSS_CHANNEL_HEADER's length so they don't need +// updating each time a new cross-channel metric is added to the constant. #[test] fn build_metrics_header_4ch() { let h = build_metrics_header(&["TP9", "AF7", "AF8", "TP10"]); - // timestamp + 4 channels × 12 bands + 46 cross-channel = 95 - assert_eq!(h.len(), 95); + assert_eq!(h.len(), 1 + 4 * 12 + METRICS_CROSS_CHANNEL_HEADER.len()); assert_eq!(h[0], "timestamp_s"); assert_eq!(h[1], "TP9_delta"); assert_eq!(h[12], "TP9_rel_high_gamma"); // last of first channel @@ -27,17 +29,17 @@ fn build_metrics_header_8ch() { .map(|i| ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "O1", "O2"][i]) .collect(); let h = build_metrics_header(&labels); - // timestamp + 8 × 12 + 46 = 143 - assert_eq!(h.len(), 143); + assert_eq!(h.len(), 1 + 8 * 12 + METRICS_CROSS_CHANNEL_HEADER.len()); assert_eq!(h[0], "timestamp_s"); - assert!(h.last().unwrap() == "gpu_tiler_pct"); + // Last header column = last cross-channel metric. Compare via the + // constant so appending new metrics doesn't require a test update. + assert_eq!(h.last().unwrap(), METRICS_CROSS_CHANNEL_HEADER.last().unwrap()); } #[test] fn build_metrics_header_1ch() { let h = build_metrics_header(&["Cz"]); - // timestamp + 1 × 12 + 46 = 59 - assert_eq!(h.len(), 59); + assert_eq!(h.len(), 1 + 1 * 12 + METRICS_CROSS_CHANNEL_HEADER.len()); } // ── CsvState: EEG write ────────────────────────────────────────────────────── diff --git a/crates/skill-devices/src/lib.rs b/crates/skill-devices/src/lib.rs index d37e4998..99ecd21f 100644 --- a/crates/skill-devices/src/lib.rs +++ b/crates/skill-devices/src/lib.rs @@ -90,6 +90,14 @@ pub fn enrich_band_snapshot(snap: &mut BandSnapshot, ctx: &SnapshotContext) { let drowsiness = compute_drowsiness(snap); snap.drowsiness = Some((drowsiness * 10.0).round() / 10.0); + // Canonical engagement / relaxation / focus — single source of truth. + let engagement = compute_engagement(snap); + snap.engagement = Some((engagement * 10.0).round() / 10.0); + let relaxation = compute_relaxation(snap); + snap.relaxation = Some((relaxation * 10.0).round() / 10.0); + let focus = compute_focus(snap); + snap.focus = Some((focus * 10.0).round() / 10.0); + // GPU stats if let Some(ref gpu) = ctx.gpu { snap.gpu_overall = Some(gpu.overall as f64); @@ -219,6 +227,67 @@ pub fn focus_score(engagement_raw: f32) -> f64 { (100.0_f32 / (1.0 + (-2.0 * (engagement_raw - 0.8)).exp())) as f64 } +// ── Canonical engagement / relaxation / focus ──────────────────────────────── +// +// Single source of truth for these three composite scores. Every consumer +// (live `latest_bands`, persisted `metrics_json`, time-series cache, websocket +// broadcasts, frontend, VS Code extension, widgets) reads identical values via +// `enrich_band_snapshot` populating `snap.engagement` / `snap.relaxation` / +// `snap.focus`. Do not re-derive these in caller code — read the snapshot. + +/// Sigmoid (0,∞) → (0,100): `100 / (1 + exp(−k·(x − mid)))`. +/// +/// Shared by both `compute_engagement` and `compute_relaxation`. Identical +/// shape to `EpochMetrics::sigmoid100` — duplicated only to keep this crate +/// dependency-free of `skill-exg`. +fn sigmoid_0_100(x: f32, k: f32, mid: f32) -> f64 { + (100.0_f32 / (1.0 + (-k * (x - mid)).exp())) as f64 +} + +/// Engagement score (0–100) — final, sigmoided. +/// +/// Per-channel β / (α + θ), with a `0.5` neutral fallback for channels whose +/// (α + θ) collapses to zero (poor electrode contact, missing band power, …). +/// Without the fallback, low-signal channels would drag the average toward +/// zero and pin the score at a constant ~16.8 — the historical "engagement +/// doesn't move" bug. +pub fn compute_engagement(snap: &BandSnapshot) -> f64 { + sigmoid_0_100(compute_engagement_raw(snap), 2.0, 0.8) +} + +/// Relaxation score (0–100) — final, sigmoided. +/// +/// Per-channel α / (β + θ), with the same `0.5` neutral fallback for +/// degenerate channels. Theta is in the denominator (matches Putman 2010 / +/// Angelidis 2016) — earlier sites that used `α / (α + β)` are deprecated. +pub fn compute_relaxation(snap: &BandSnapshot) -> f64 { + if snap.channels.is_empty() { + return sigmoid_0_100(0.5, 2.5, 1.0); + } + let n = snap.channels.len() as f32; + let raw: f32 = snap + .channels + .iter() + .map(|ch| { + let d = ch.rel_beta + ch.rel_theta; + if d > 1e-6 { + ch.rel_alpha / d + } else { + 0.5 + } + }) + .sum::() + / n; + sigmoid_0_100(raw, 2.5, 1.0) +} + +/// Focus score (0–100). Currently the same as engagement; kept distinct so +/// the UI can surface a "focus" label and so the formula can diverge later +/// without another rename across consumers. +pub fn compute_focus(snap: &BandSnapshot) -> f64 { + focus_score(compute_engagement_raw(snap)) +} + // ── Battery EMA ─────────────────────────────────────────────────────────────── /// Exponential moving average for battery level with low-battery alerts. @@ -510,6 +579,7 @@ mod tests { sample_entropy: 0.4, pac_theta_gamma: 0.1, laterality_index: 0.05, + echt: 0.5, headache_index: 10.0, migraine_index: 5.0, consciousness_lzc: 50.0, @@ -534,6 +604,9 @@ mod tests { meditation: None, cognitive_load: None, drowsiness: None, + engagement: None, + relaxation: None, + focus: None, temperature_raw: None, gpu_overall: None, gpu_render: None, @@ -572,6 +645,97 @@ mod tests { assert!(focus_score(1.0) <= 100.0); } + /// End-to-end sanity: enriching a snapshot must populate engagement / + /// relaxation / focus, and the values must equal the canonical compute_* + /// functions. Locks down the single-source-of-truth contract so any future + /// regression where a caller computes its own metric will fail loudly. + #[test] + fn enrich_populates_canonical_engagement_relaxation_focus() { + let mut snap = test_snap(); + let ctx = SnapshotContext { + ppg: None, + artifacts: None, + head_pose: None, + temperature_raw: 0, + gpu: None, + }; + // Canonical values, computed *before* enrichment so the snapshot is + // unmutated — proves the enrich path doesn't have hidden state. + let want_engagement = compute_engagement(&snap); + let want_relaxation = compute_relaxation(&snap); + let want_focus = compute_focus(&snap); + + enrich_band_snapshot(&mut snap, &ctx); + + let got_e = snap.engagement.expect("engagement populated"); + let got_r = snap.relaxation.expect("relaxation populated"); + let got_f = snap.focus.expect("focus populated"); + + // Snapshot values are rounded to 1 decimal; canonical values are not. + assert!( + (got_e - want_engagement).abs() < 0.05, + "engagement mismatch: {got_e} vs {want_engagement}" + ); + assert!( + (got_r - want_relaxation).abs() < 0.05, + "relaxation mismatch: {got_r} vs {want_relaxation}" + ); + assert!( + (got_f - want_focus).abs() < 0.05, + "focus mismatch: {got_f} vs {want_focus}" + ); + + // Sanity: scores are 0–100. + for (name, v) in [("engagement", got_e), ("relaxation", got_r), ("focus", got_f)] { + assert!((0.0..=100.0).contains(&v), "{name}={v} out of range"); + } + } + + /// Reproduces the stuck-engagement failure mode: channels with + /// `rel_alpha + rel_theta ≈ 0`. Pre-refactor this drove engagement to a + /// constant ~16.8. Post-refactor the per-channel 0.5 fallback keeps the + /// score at the neutral midpoint and — critically — makes it *move* when + /// other channels recover signal. + #[test] + fn engagement_does_not_collapse_on_zero_alpha_theta() { + // All channels: alpha=0, theta=0, beta>0. Pre-refactor: stuck-low ~16.8. + let mut snap = test_snap(); + for ch in &mut snap.channels { + ch.alpha = 0.0; + ch.theta = 0.0; + ch.beta = 1.0; + ch.rel_alpha = 0.0; + ch.rel_theta = 0.0; + ch.rel_beta = 1.0; + } + + let stuck_low = compute_engagement(&snap); + // Should be at the neutral-fallback midpoint, not pinned at ~16.8. + assert!( + stuck_low > 30.0, + "engagement collapsed to {stuck_low} on zero-α+θ channels" + ); + + // Now flip one channel to a high-engagement profile and verify the + // score *moves* — the original bug was that it didn't. + snap.channels[0].rel_alpha = 0.10; + snap.channels[0].rel_theta = 0.10; + // Same rel_beta=1.0 → β/(α+θ) = 5 → strong engagement signal. + let moved = compute_engagement(&snap); + assert!( + moved > stuck_low + 1.0, + "engagement did not move: {stuck_low} -> {moved}" + ); + } + + /// Storage-side parity: `EpochMetrics::from_snapshot` (in `skill-exg`) + /// must produce the same engagement/relaxation as the canonical compute + /// functions. We can't import skill-exg here without a dep cycle, so this + /// test lives in `skill-exg`'s own test suite — see + /// `crates/skill-exg/src/lib.rs::tests::epoch_metrics_match_canonical`. + #[test] + fn _see_epoch_metrics_match_canonical_in_skill_exg() {} + #[test] fn battery_ema_first_reading() { let mut b = BatteryEma::new(0.1); diff --git a/crates/skill-eeg/Cargo.toml b/crates/skill-eeg/Cargo.toml index 8a178161..11728efc 100644 --- a/crates/skill-eeg/Cargo.toml +++ b/crates/skill-eeg/Cargo.toml @@ -7,14 +7,19 @@ description = "EEG signal processing for NeuroSkill — extracted workspace crat [features] default = [] -gpu = ["dep:gpu-fft", "gpu-fft/wgpu"] -mlx = ["gpu", "gpu-fft/mlx"] +# GPU-accelerated FFT via RLX — Metal on macOS, CUDA/wgpu/CPU elsewhere. +# rlx-fft enables the rlx CPU baseline; the tier suffixes add GPU backends. +rlx-fft = ["dep:rlx", "rlx/cpu"] +rlx-fft-metal = ["rlx-fft", "rlx/metal"] +rlx-fft-cuda = ["rlx-fft", "rlx/cuda"] +rlx-fft-gpu = ["rlx-fft", "rlx/gpu"] +rlx-fft-rocm = ["rlx-fft", "rlx/rocm"] [dependencies] skill-constants = { path = "../skill-constants" } serde = { version = "1", features = ["derive"] } serde_json = "1" -gpu-fft = { version = "1.2.0", default-features = false, optional = true } +rlx = { workspace = true, optional = true } rustfft = "6" [dev-dependencies] diff --git a/crates/skill-eeg/benches/dsp_bench.rs b/crates/skill-eeg/benches/dsp_bench.rs index 7996d6c0..a9adde52 100644 --- a/crates/skill-eeg/benches/dsp_bench.rs +++ b/crates/skill-eeg/benches/dsp_bench.rs @@ -3,44 +3,119 @@ //! Benchmarks for EEG DSP hot paths — FFT, band-power analysis, and filtering. //! //! Run: `cargo bench -p skill-eeg` +//! Run (Metal): `cargo bench -p skill-eeg --features rlx-fft-metal` -#[cfg(not(feature = "gpu"))] -use criterion::BenchmarkId; -use criterion::{criterion_group, criterion_main, Criterion}; +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use skill_eeg::eeg_bands::BandAnalyzer; use skill_eeg::eeg_filter::{EegFilter, FilterConfig}; use std::hint::black_box; -#[cfg(not(feature = "gpu"))] +// ── FFT benchmarks: backed by whichever FFT path is compiled in ─────────────── +// +// Sweeps batch × n to show the GPU crossover point. +// Batches model EEG channel counts: 1 ch (single), 8 ch (Mendi/MW75), +// 32 ch (research cap), 64 ch (dense array). + +const FFT_SIZES: &[usize] = &[128, 256, 512, 1024]; +const BATCH_SIZES: &[usize] = &[1, 8, 32, 64]; + +#[cfg(feature = "rlx-fft")] +fn bench_fft(c: &mut Criterion) { + use skill_eeg::rlx_fft::{fft_batch, ifft_batch}; + + let mut group = c.benchmark_group("fft_batch"); + for &batch in BATCH_SIZES { + for &n in FFT_SIZES { + let signals: Vec> = (0..batch) + .map(|ch| (0..n).map(|i| ((i + ch) as f32 * 0.1).sin()).collect()) + .collect(); + let total_samples = (batch * n) as u64; + group.throughput(Throughput::Elements(total_samples)); + group.bench_with_input(BenchmarkId::new(format!("b{batch}"), n), &signals, |b, s| { + b.iter(|| fft_batch(black_box(s))); + }); + } + } + group.finish(); + + let mut group = c.benchmark_group("ifft_batch"); + for &batch in BATCH_SIZES { + for &n in FFT_SIZES { + let signals: Vec> = (0..batch) + .map(|ch| (0..n).map(|i| ((i + ch) as f32 * 0.1).sin()).collect()) + .collect(); + let spectra = fft_batch(&signals); + let total_samples = (batch * n) as u64; + group.throughput(Throughput::Elements(total_samples)); + group.bench_with_input(BenchmarkId::new(format!("b{batch}"), n), &spectra, |b, s| { + b.iter(|| ifft_batch(black_box(s))); + }); + } + } + group.finish(); +} + +#[cfg(not(any(feature = "rlx-fft", feature = "gpu")))] fn bench_fft(c: &mut Criterion) { use skill_eeg::cpu_fft::{fft_batch, ifft_batch}; - let mut group = c.benchmark_group("fft"); - for &size in &[128, 256, 512, 1024] { - let signal: Vec = (0..size).map(|i| (i as f32 * 0.1).sin()).collect(); - group.bench_with_input(BenchmarkId::new("fft_batch", size), &signal, |b, s| { - b.iter(|| fft_batch(black_box(&[s.clone()]))); - }); + + let mut group = c.benchmark_group("fft_batch"); + for &batch in BATCH_SIZES { + for &n in FFT_SIZES { + let signals: Vec> = (0..batch) + .map(|ch| (0..n).map(|i| ((i + ch) as f32 * 0.1).sin()).collect()) + .collect(); + let total_samples = (batch * n) as u64; + group.throughput(Throughput::Elements(total_samples)); + group.bench_with_input(BenchmarkId::new(format!("b{batch}"), n), &signals, |b, s| { + b.iter(|| fft_batch(black_box(s))); + }); + } } group.finish(); - let mut group = c.benchmark_group("ifft"); + let mut group = c.benchmark_group("ifft_batch"); + for &batch in BATCH_SIZES { + for &n in FFT_SIZES { + let signals: Vec> = (0..batch) + .map(|ch| (0..n).map(|i| ((i + ch) as f32 * 0.1).sin()).collect()) + .collect(); + let spectra = fft_batch(&signals); + let total_samples = (batch * n) as u64; + group.throughput(Throughput::Elements(total_samples)); + group.bench_with_input(BenchmarkId::new(format!("b{batch}"), n), &spectra, |b, s| { + b.iter(|| ifft_batch(black_box(s))); + }); + } + } + group.finish(); +} + +// ── PSD benchmarks ──────────────────────────────────────────────────────────── + +#[cfg(feature = "rlx-fft")] +fn bench_psd(c: &mut Criterion) { + use skill_eeg::rlx_fft::psd; + let mut group = c.benchmark_group("psd"); for &size in &[128, 256, 512, 1024] { - let signal: Vec = (0..size).map(|i| (i as f32 * 0.1).sin()).collect(); - let spectra = fft_batch(&[signal]); - group.bench_with_input(BenchmarkId::new("ifft_batch", size), &spectra, |b, s| { - b.iter(|| ifft_batch(black_box(s))); + let real: Vec = (0..size).map(|i| (i as f32 * 0.1).sin()).collect(); + let imag: Vec = (0..size).map(|i| (i as f32 * 0.1).cos()).collect(); + group.throughput(Throughput::Elements(size as u64)); + group.bench_with_input(BenchmarkId::new("psd", size), &size, |b, _| { + b.iter(|| psd(black_box(&real), black_box(&imag))); }); } group.finish(); } -#[cfg(not(feature = "gpu"))] +#[cfg(not(any(feature = "rlx-fft", feature = "gpu")))] fn bench_psd(c: &mut Criterion) { use skill_eeg::cpu_fft::psd; let mut group = c.benchmark_group("psd"); - for &size in &[128, 256, 512] { + for &size in &[128, 256, 512, 1024] { let real: Vec = (0..size).map(|i| (i as f32 * 0.1).sin()).collect(); let imag: Vec = (0..size).map(|i| (i as f32 * 0.1).cos()).collect(); + group.throughput(Throughput::Elements(size as u64)); group.bench_with_input(BenchmarkId::new("psd", size), &size, |b, _| { b.iter(|| psd(black_box(&real), black_box(&imag))); }); @@ -48,47 +123,66 @@ fn bench_psd(c: &mut Criterion) { group.finish(); } +// ── Band analyzer & filter (realistic multi-channel) ───────────────────────── + fn bench_band_analyzer(c: &mut Criterion) { - let mut analyzer = BandAnalyzer::new_with_rate(256.0); - // Generate 1 second of synthetic EEG at 256 Hz - let samples: Vec = (0..256) - .map(|i| { - let t = i as f64 / 256.0; - // Mix of alpha (10 Hz) and beta (20 Hz) - (10.0 * std::f64::consts::TAU * t).sin() + 0.5 * (20.0 * std::f64::consts::TAU * t).sin() - }) - .collect(); - - c.bench_function("band_analyzer_push_256", |b| { - b.iter(|| { - analyzer.push(black_box(0), black_box(&samples)); - analyzer.reset(); - }); - }); + let mut group = c.benchmark_group("band_analyzer"); + for &num_channels in &[1usize, 8, 32] { + for &n_samples in &[256usize, 512] { + let samples: Vec = (0..n_samples) + .map(|i| { + let t = i as f64 / 256.0; + (10.0 * std::f64::consts::TAU * t).sin() + 0.5 * (20.0 * std::f64::consts::TAU * t).sin() + }) + .collect(); + group.throughput(Throughput::Elements((num_channels * n_samples) as u64)); + group.bench_with_input( + BenchmarkId::new(format!("ch{num_channels}"), n_samples), + &samples, + |b, s| { + let mut analyzer = BandAnalyzer::new_with_rate(256.0); + b.iter(|| { + for ch in 0..num_channels { + analyzer.push(black_box(ch), black_box(s)); + } + analyzer.reset(); + }); + }, + ); + } + } + group.finish(); } fn bench_filter(c: &mut Criterion) { - let config = FilterConfig::full_band_us(); - let samples: Vec = (0..256) - .map(|i| { - let t = i as f64 / 256.0; - (10.0 * std::f64::consts::TAU * t).sin() - }) - .collect(); - - c.bench_function("eeg_filter_push_256", |b| { - let mut filter = EegFilter::new(config); - b.iter(|| { - filter.push(black_box(0), black_box(&samples)); - let _ = filter.drain(0); - }); - }); + let mut group = c.benchmark_group("eeg_filter"); + for &num_channels in &[1usize, 8, 32] { + for &n_samples in &[256usize, 512] { + let samples: Vec = (0..n_samples) + .map(|i| { + let t = i as f64 / 256.0; + (10.0 * std::f64::consts::TAU * t).sin() + }) + .collect(); + let config = FilterConfig::full_band_us(); + group.throughput(Throughput::Elements((num_channels * n_samples) as u64)); + group.bench_with_input( + BenchmarkId::new(format!("ch{num_channels}"), n_samples), + &samples, + |b, s| { + let mut filter = EegFilter::new(config); + b.iter(|| { + for ch in 0..num_channels { + filter.push(black_box(ch), black_box(s)); + let _ = filter.drain(ch); + } + }); + }, + ); + } + } + group.finish(); } -#[cfg(not(feature = "gpu"))] criterion_group!(benches, bench_fft, bench_psd, bench_band_analyzer, bench_filter); - -#[cfg(feature = "gpu")] -criterion_group!(benches, bench_band_analyzer, bench_filter); - criterion_main!(benches); diff --git a/crates/skill-eeg/src/band_metrics.rs b/crates/skill-eeg/src/band_metrics.rs index 4e007e71..57777362 100644 --- a/crates/skill-eeg/src/band_metrics.rs +++ b/crates/skill-eeg/src/band_metrics.rs @@ -410,6 +410,70 @@ pub(crate) fn laterality_index_fn(ch: &[BandPowers]) -> f32 { } } +/// Endpoint-Corrected Hilbert Transform — alpha-band rhythmicity (0–1). +/// +/// Estimates instantaneous phase of the alpha band (≈10 Hz) via a **causal** +/// complex-Morlet kernel: only past samples contribute to each estimate, so the +/// phase at the most recent sample is not corrupted by missing future samples +/// (the failure mode of FFT-based Hilbert at the buffer edge). +/// +/// Returns the resultant length of the detrended phase sequence — i.e. how +/// concentrated the inter-sample phase is around the expected advance +/// `2π·f0/sr`. 1.0 = perfectly rhythmic alpha; 0.0 = phase-random. +/// +/// Reference: Schreglmann et al., *Nat. Commun.* 12:363 (2021), +/// doi:10.1038/s41467-020-20581-7. +pub(crate) fn echt_fn(x: &[f32], sr: f32) -> f32 { + if sr <= 0.0 || x.len() < 64 { + return 0.0; + } + let f0: f32 = 10.0; // alpha center + let cycles: f32 = 5.0; // ≈ 2 Hz bandwidth + let kernel_len = (((cycles / f0) * sr).round() as usize).clamp(8, x.len() / 2); + let omega = 2.0 * std::f32::consts::PI * f0 / sr; + let sigma = kernel_len as f32 / 6.0; + let mid = kernel_len as f32 / 2.0; + let two_sig2 = 2.0 * sigma * sigma; + + // Precompute the causal complex-Morlet kernel. + let mut k_re = vec![0.0f32; kernel_len]; + let mut k_im = vec![0.0f32; kernel_len]; + for j in 0..kernel_len { + let t = j as f32; + let env = (-((t - mid).powi(2)) / two_sig2).exp(); + let arg = omega * t; + // Demodulator kernel exp(-iωt): negate imaginary part to shift the + // positive-frequency component down to baseband. + k_re[j] = env * arg.cos(); + k_im[j] = -env * arg.sin(); + } + + let mut cs_acc = 0.0f32; + let mut sn_acc = 0.0f32; + let mut count: u32 = 0; + for k in (kernel_len - 1)..x.len() { + let mut re = 0.0f32; + let mut im = 0.0f32; + let base = k + 1 - kernel_len; + for j in 0..kernel_len { + let xv = x[base + j]; + re += xv * k_re[j]; + im += xv * k_im[j]; + } + // Detrend by subtracting the expected phase advance ω·k so that a + // perfectly rhythmic oscillation maps to a constant residual phase. + let phase = im.atan2(re) - omega * k as f32; + cs_acc += phase.cos(); + sn_acc += phase.sin(); + count += 1; + } + if count == 0 { + return 0.0; + } + let n = count as f32; + ((cs_acc / n).powi(2) + (sn_acc / n).powi(2)).sqrt().clamp(0.0, 1.0) +} + /// Simple linear regression slope. fn lin_reg_slope(x: &[f64], y: &[f64]) -> f64 { let n = x.len() as f64; @@ -506,4 +570,36 @@ mod tests { let hfd = higuchi_fd(&signal); assert!(hfd > 0.0 && hfd < 3.0, "HFD={hfd} should be between 0 and 3"); } + + #[test] + fn echt_pure_alpha_is_rhythmic() { + // 10 Hz sinusoid sampled at 256 Hz → ECHT should be close to 1. + let sr = 256.0_f32; + let signal: Vec = (0..512) + .map(|i| (2.0 * std::f32::consts::PI * 10.0 * i as f32 / sr).sin()) + .collect(); + let r = echt_fn(&signal, sr); + assert!(r > 0.9, "pure 10 Hz sine should give R>0.9, got {r}"); + } + + #[test] + fn echt_white_noise_is_low() { + // Deterministic pseudo-random sequence (LCG) → low rhythmicity. + let mut s: u32 = 1; + let signal: Vec = (0..512) + .map(|_| { + s = s.wrapping_mul(1664525).wrapping_add(1013904223); + (s as f32 / u32::MAX as f32) - 0.5 + }) + .collect(); + let r = echt_fn(&signal, 256.0); + assert!(r < 0.5, "white-noise ECHT should be low, got {r}"); + } + + #[test] + fn echt_short_or_invalid_input() { + assert_eq!(echt_fn(&[], 256.0), 0.0); + assert_eq!(echt_fn(&[0.0; 32], 256.0), 0.0); + assert_eq!(echt_fn(&[0.0; 256], 0.0), 0.0); + } } diff --git a/crates/skill-eeg/src/eeg_bands.rs b/crates/skill-eeg/src/eeg_bands.rs index 5c9805af..04b4dabd 100644 --- a/crates/skill-eeg/src/eeg_bands.rs +++ b/crates/skill-eeg/src/eeg_bands.rs @@ -42,14 +42,12 @@ //! P_band = Σ_k factor × psd_raw[k] / Σ wᵢ² //! ``` //! -//! where `psd_raw[k] = (r[k]² + i[k]²) / n` is the output of -//! `gpu_fft::psd::psd`. +//! where `psd_raw[k] = (r[k]² + i[k]²) / n`. //! //! ## Batch GPU execution //! -//! All 4 channels are submitted together as a `4 × 512` matrix in one -//! `fft_batch` call — the GPU kernel covers all channels in a single 2-D -//! workgroup dispatch with no per-channel overhead. +//! All channels are submitted together as a `batch × n` matrix in one +//! `fft_batch` call — the GPU kernel covers all channels in a single dispatch. //! //! ## Bands //! @@ -66,10 +64,10 @@ use std::collections::VecDeque; use std::f32::consts::PI; use std::time::{SystemTime, UNIX_EPOCH}; -#[cfg(not(feature = "gpu"))] +#[cfg(not(feature = "rlx-fft"))] use crate::cpu_fft::{fft_batch, psd}; -#[cfg(feature = "gpu")] -use gpu_fft::{fft_batch, psd::psd}; +#[cfg(feature = "rlx-fft")] +use crate::rlx_fft::{fft_batch, psd}; use serde::{Deserialize, Serialize}; use crate::band_metrics::*; @@ -208,6 +206,10 @@ pub struct BandSnapshot { /// Laterality Index — generalised L/R asymmetry across all bands. [Homan 1987] pub laterality_index: f32, + /// Endpoint-Corrected Hilbert Transform — alpha-band rhythmicity (0–1) + /// from causal-Morlet instantaneous phase. [Schreglmann et al. 2021] + pub echt: f32, + // ── Headache / Migraine EEG correlate indices (0–100) ─────────────────── // Research biomarkers derived from published literature. // NOT clinical diagnostic tools — for informational/research purposes only. @@ -289,6 +291,18 @@ pub struct BandSnapshot { /// Drowsiness score (0–100). High TAR + alpha spindles. #[serde(skip_serializing_if = "Option::is_none")] pub drowsiness: Option, + /// Engagement score (0–100). Per-channel β / (α + θ), per-channel `0.5` + /// fallback for low-signal channels, then sigmoid. + #[serde(skip_serializing_if = "Option::is_none")] + pub engagement: Option, + /// Relaxation score (0–100). Per-channel α / (β + θ), per-channel `0.5` + /// fallback for low-signal channels, then sigmoid. + #[serde(skip_serializing_if = "Option::is_none")] + pub relaxation: Option, + /// Focus score (0–100). Currently identical to `engagement`; kept as a + /// distinct field for UI semantics and future divergence. + #[serde(skip_serializing_if = "Option::is_none")] + pub focus: Option, // ── Device telemetry ───────────────────────────────────────────────────── /// Raw temperature ADC value from headset (Classic firmware only). @@ -488,7 +502,7 @@ impl BandAnalyzer { // One-sided PSD (Heinzel et al. 2002 normalisation): // S[k] = factor × |X[k]|² / (fs × Σwᵢ²) [µV²/Hz] // - // With psd_raw[k] = |X[k]|² / n (from gpu_fft::psd::psd): + // With psd_raw[k] = |X[k]|² / n (output of `psd()`): // S[k] = factor × n × psd_raw[k] / (fs × Σwᵢ²) // // Band power (µV²): @@ -828,6 +842,7 @@ impl BandAnalyzer { let mut dfa_sum = 0.0f32; let mut se_sum = 0.0f32; let mut pac_sum = 0.0f32; + let mut echt_sum = 0.0f32; for ch_idx in 0..EEG_CHANNELS { let raw: Vec = self.window[ch_idx].iter().copied().collect(); let (ha, hm, hc) = hjorth_params(&raw); @@ -839,6 +854,7 @@ impl BandAnalyzer { dfa_sum += dfa_exponent(&raw); se_sum += sample_entropy_fn(&raw); pac_sum += pac_theta_gamma_fn(&raw, self.sample_rate); + echt_sum += echt_fn(&raw, self.sample_rate); } let hjorth_activity = ha_sum / safe_nch; let hjorth_mobility = hm_sum / safe_nch; @@ -848,6 +864,7 @@ impl BandAnalyzer { let dfa_exponent_val = dfa_sum / safe_nch; let sample_entropy_val = se_sum / safe_nch; let pac_theta_gamma = pac_sum / safe_nch; + let echt = echt_sum / safe_nch; // ── Laterality Index ───────────────────────────────────────────────── let laterality_index = laterality_index_fn(&ch_powers); @@ -977,6 +994,7 @@ impl BandAnalyzer { sample_entropy: sample_entropy_val, pac_theta_gamma, laterality_index, + echt, headache_index, migraine_index, consciousness_lzc, @@ -1001,6 +1019,9 @@ impl BandAnalyzer { meditation: None, cognitive_load: None, drowsiness: None, + engagement: None, + relaxation: None, + focus: None, temperature_raw: None, gpu_overall: None, gpu_render: None, diff --git a/crates/skill-eeg/src/eeg_filter.rs b/crates/skill-eeg/src/eeg_filter.rs index a1398ffb..0360e948 100644 --- a/crates/skill-eeg/src/eeg_filter.rs +++ b/crates/skill-eeg/src/eeg_filter.rs @@ -4,7 +4,7 @@ // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, version 3 only. -//! GPU-accelerated EEG signal filtering using the `gpu-fft` crate (wgpu backend). +//! EEG signal filtering — GPU-accelerated via RLX (Metal/CUDA/wgpu) or CPU rustfft. //! //! ## Algorithm: Overlap-Save //! @@ -60,10 +60,10 @@ use std::collections::VecDeque; use std::time::{SystemTime, UNIX_EPOCH}; -#[cfg(not(any(feature = "gpu", feature = "mlx")))] +#[cfg(not(feature = "rlx-fft"))] use crate::cpu_fft::{fft_batch, ifft_batch, psd as one_sided_psd}; -#[cfg(any(feature = "gpu", feature = "mlx"))] -use gpu_fft::{fft_batch, ifft_batch, psd::psd as one_sided_psd}; +#[cfg(feature = "rlx-fft")] +use crate::rlx_fft::{fft_batch, ifft_batch, psd as one_sided_psd}; use serde::{Deserialize, Serialize}; use crate::constants::{ @@ -263,15 +263,17 @@ impl FilterConfig { /// /// ### Usage /// -/// ```rust,ignore +/// ```no_run +/// use skill_eeg::eeg_filter::{EegFilter, FilterConfig}; /// let mut filter = EegFilter::new(FilterConfig::default()); -/// +/// # let electrode: usize = 0; +/// # let raw_samples: Vec = vec![0.0; 256]; /// // Called once per incoming Muse EEG packet: /// if filter.push(electrode, &raw_samples) { /// // Filtered output is ready for all channels. -/// for ch in 0..EEG_CHANNELS { +/// for ch in 0..4 { /// let filtered: Vec = filter.drain(ch); -/// // … forward to frontend … +/// let _ = filtered; // … forward to frontend … /// } /// } /// ``` @@ -452,7 +454,7 @@ impl EegFilter { // sampled HERE, before the frequency mask zeroes any bins, so the // spectrogram reflects the true unfiltered spectrum. // - // `one_sided_psd` (= gpu_fft::psd::psd) returns (r²+i²)/n for each bin. + // `one_sided_psd` returns (r²+i²)/n for each bin. // We take only the first SPEC_N_FREQ = 51 bins (0 Hz … 50 Hz at 1 Hz/bin). { let n_spec = SPEC_N_FREQ.min(n / 2 + 1); diff --git a/crates/skill-eeg/src/eeg_model_config.rs b/crates/skill-eeg/src/eeg_model_config.rs index 765f83a6..a4272248 100644 --- a/crates/skill-eeg/src/eeg_model_config.rs +++ b/crates/skill-eeg/src/eeg_model_config.rs @@ -17,7 +17,8 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use crate::constants::{ - HNSW_EF_CONSTRUCTION, HNSW_M, LUNA_DEFAULT_VARIANT, LUNA_HF_REPO, MODEL_CONFIG_FILE, ZUNA_DATA_NORM, ZUNA_HF_REPO, + EEGDINO_DEFAULT_VARIANT, EEGDINO_HF_REPO, EEGDINO_VARIANTS, HNSW_EF_CONSTRUCTION, HNSW_M, LUNA_DEFAULT_VARIANT, + LUNA_HF_REPO, MODEL_CONFIG_FILE, ZUNA_DATA_NORM, ZUNA_HF_REPO, }; // ── EXG embedding model backend ────────────────────────────────────────────── @@ -55,6 +56,8 @@ pub enum ExgModelBackend { Tribev2, /// NeuroRVQ tokenizer — residual vector quantization for EEG/ECG/EMG. Neurorvq, + /// EEG-DINO — hierarchical self-distillation EEG foundation model (RLX). + Eegdino, /// ST-EEGFormer — ViT-based EEG foundation model (NeurIPS 2025 winner, ICLR 2026). Steegformer, } @@ -82,6 +85,7 @@ impl ExgModelBackend { "opentslm" => Self::Opentslm, "tribev2" => Self::Tribev2, "neurorvq" => Self::Neurorvq, + "eegdino" => Self::Eegdino, "steegformer" => Self::Steegformer, _ => Self::Zuna, } @@ -104,6 +108,7 @@ impl ExgModelBackend { Self::Opentslm => "opentslm", Self::Tribev2 => "tribev2", Self::Neurorvq => "neurorvq", + Self::Eegdino => "eegdino", Self::Steegformer => "steegformer", } } @@ -166,6 +171,14 @@ pub struct ExgModelConfig { /// HuggingFace repository for LUNA weights. #[serde(default = "default_luna_hf_repo")] pub luna_hf_repo: String, + + /// EEG-DINO model size variant: `"small"`, `"medium"`, or `"large"`. + #[serde(default = "default_eegdino_variant")] + pub eegdino_variant: String, + + /// HuggingFace repository for EEG-DINO weights. + #[serde(default = "default_eegdino_hf_repo")] + pub eegdino_hf_repo: String, } fn default_hf_repo() -> String { @@ -186,6 +199,12 @@ fn default_luna_variant() -> String { fn default_luna_hf_repo() -> String { LUNA_HF_REPO.to_string() } +fn default_eegdino_variant() -> String { + EEGDINO_DEFAULT_VARIANT.to_string() +} +fn default_eegdino_hf_repo() -> String { + EEGDINO_HF_REPO.to_string() +} /// Legacy alias — old configs may have the old struct name. pub type EegModelConfig = ExgModelConfig; @@ -200,6 +219,8 @@ impl Default for ExgModelConfig { model_backend: ExgModelBackend::default(), luna_variant: default_luna_variant(), luna_hf_repo: default_luna_hf_repo(), + eegdino_variant: default_eegdino_variant(), + eegdino_hf_repo: default_eegdino_hf_repo(), } } } @@ -215,6 +236,15 @@ impl ExgModelConfig { .map(|(_, f)| *f) .unwrap_or(crate::constants::LUNA_VARIANTS[0].1) } + + /// Return the EEG-DINO safetensors filename for the current variant. + pub fn eegdino_weights_file(&self) -> &'static str { + EEGDINO_VARIANTS + .iter() + .find(|(v, _)| *v == self.eegdino_variant.as_str()) + .map(|(_, f)| *f) + .unwrap_or(EEGDINO_VARIANTS[0].1) + } } // ── Runtime status (not persisted) ─────────────────────────────────────────── @@ -348,6 +378,7 @@ pub struct LatestEpochMetrics { pub sample_entropy: f32, pub pac_theta_gamma: f32, pub laterality_index: f32, + pub echt: f32, // PPG-derived pub hr: f64, pub rmssd: f64, @@ -459,6 +490,7 @@ mod tests { model_backend: ExgModelBackend::Luna, luna_variant: "large".into(), luna_hf_repo: "PulpBio/LUNA".into(), + ..Default::default() }; let json = serde_json::to_string(&cfg).unwrap(); let parsed: EegModelConfig = serde_json::from_str(&json).unwrap(); @@ -539,6 +571,7 @@ mod tests { model_backend: ExgModelBackend::Luna, luna_variant: "huge".into(), luna_hf_repo: "PulpBio/LUNA".into(), + ..Default::default() }; save_model_config(&dir, &cfg); let loaded = load_model_config(&dir); @@ -586,6 +619,7 @@ mod tests { ExgModelBackend::Opentslm, ExgModelBackend::Tribev2, ExgModelBackend::Neurorvq, + ExgModelBackend::Eegdino, ExgModelBackend::Steegformer, ]; diff --git a/crates/skill-eeg/src/lib.rs b/crates/skill-eeg/src/lib.rs index 92ba3d8f..c0483c0d 100644 --- a/crates/skill-eeg/src/lib.rs +++ b/crates/skill-eeg/src/lib.rs @@ -18,10 +18,14 @@ pub mod constants { pub use skill_constants::*; } -/// CPU-based FFT fallback (rustfft) used when neither `gpu` nor `mlx` is enabled. -#[cfg(not(any(feature = "gpu", feature = "mlx")))] +/// GPU-accelerated FFT via RLX (Metal / CUDA / wgpu / CPU). +#[cfg(feature = "rlx-fft")] +pub mod rlx_fft; + +/// CPU-based FFT fallback (rustfft) used when `rlx-fft` is not enabled. +#[cfg(not(feature = "rlx-fft"))] pub mod cpu_fft; -#[cfg(all(test, not(any(feature = "gpu", feature = "mlx"))))] +#[cfg(all(test, not(feature = "rlx-fft")))] mod proptest_tests; pub mod artifact_detection; diff --git a/crates/skill-eeg/src/proptest_tests.rs b/crates/skill-eeg/src/proptest_tests.rs index b6ef306e..7fc75e24 100644 --- a/crates/skill-eeg/src/proptest_tests.rs +++ b/crates/skill-eeg/src/proptest_tests.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 NeuroSkill.com //! Property-based tests for EEG signal processing primitives. -#[cfg(all(test, not(feature = "gpu")))] +#[cfg(all(test, not(feature = "rlx-fft")))] mod tests { use crate::cpu_fft::{fft_batch, ifft_batch, psd}; use proptest::prelude::*; diff --git a/crates/skill-eeg/src/rlx_fft.rs b/crates/skill-eeg/src/rlx_fft.rs new file mode 100644 index 00000000..637c6afc --- /dev/null +++ b/crates/skill-eeg/src/rlx_fft.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! GPU-accelerated FFT via RLX — Metal on macOS, CUDA → wgpu → CPU elsewhere. +//! +//! Provides the same `fft_batch` / `ifft_batch` / `psd` surface as `cpu_fft` +//! but dispatches to the rlx runtime using whichever backend was selected at +//! daemon startup via [`init_device`]. +//! +//! Compiled graphs are cached by `(device, batch, n)` so the first call for a +//! given shape compiles once; subsequent calls reuse the cached executable. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use rlx::ir::fft::FftNorm; +use rlx::{CompiledGraph, DType, Device, Graph, Session, Shape}; + +// ── Device selection ───────────────────────────────────────────────────────── + +static FFT_DEVICE: OnceLock = OnceLock::new(); + +/// Set the RLX device used for FFT dispatch. Call once at daemon startup +/// (e.g. from `pipeline.rs`) before the first `fft_batch` call. +/// Subsequent calls are silent no-ops. +pub fn init_device(device: Device) { + let _ = FFT_DEVICE.set(device); +} + +fn current_device() -> Device { + FFT_DEVICE.get().copied().unwrap_or(Device::Cpu) +} + +fn device_tag(d: Device) -> u8 { + match d { + Device::Cpu => 0, + Device::Metal => 1, + Device::Mlx => 2, + Device::Cuda => 3, + Device::Gpu => 4, + Device::Rocm => 5, + _ => 255, + } +} + +// ── Kernel cache ───────────────────────────────────────────────────────────── + +type CacheKey = (u8, usize, usize); // (device_tag, batch, n) +type CacheMap = Mutex>>>; + +static FWD_CACHE: OnceLock = OnceLock::new(); +static INV_CACHE: OnceLock = OnceLock::new(); + +fn fwd_cache() -> &'static CacheMap { + FWD_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn inv_cache() -> &'static CacheMap { + INV_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn get_or_compile_fwd(device: Device, batch: usize, n: usize) -> Arc> { + let key = (device_tag(device), batch, n); + { + let lock = fwd_cache().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(g) = lock.get(&key) { + return Arc::clone(g); + } + } + // Compile: real input [batch, n] → auto-pads to next pow2 → (re, im) [batch, n_pad]. + let mut g = Graph::new("eeg_fft_fwd"); + let x = g.input("x", Shape::new(&[batch, n], DType::F32)); + let (re, im) = g.fft_batch_real(x, FftNorm::Forward); + g.set_outputs(vec![re, im]); + let compiled = Arc::new(Mutex::new(Session::new(device).compile(g))); + + let mut lock = fwd_cache().lock().unwrap_or_else(|e| e.into_inner()); + Arc::clone(lock.entry(key).or_insert(compiled)) +} + +fn get_or_compile_inv(device: Device, batch: usize, n_pad: usize) -> Arc> { + let key = (device_tag(device), batch, n_pad); + { + let lock = inv_cache().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(g) = lock.get(&key) { + return Arc::clone(g); + } + } + // Compile: (re, im) [batch, n_pad] → real time-domain [batch, n_pad]. + let mut g = Graph::new("eeg_fft_inv"); + let re = g.input("re", Shape::new(&[batch, n_pad], DType::F32)); + let im = g.input("im", Shape::new(&[batch, n_pad], DType::F32)); + let out = g.ifft_spectrum(re, im, FftNorm::Forward); + g.set_outputs(vec![out]); + let compiled = Arc::new(Mutex::new(Session::new(device).compile(g))); + + let mut lock = inv_cache().lock().unwrap_or_else(|e| e.into_inner()); + Arc::clone(lock.entry(key).or_insert(compiled)) +} + +// ── Public API (same surface as cpu_fft) ───────────────────────────────────── + +/// Batched forward FFT. +/// +/// All signals must have the same length. Returns `(real, imag)` pairs, each +/// of length `n.next_power_of_two()`, matching the `cpu_fft::fft_batch` layout. +pub fn fft_batch(signals: &[Vec]) -> Vec<(Vec, Vec)> { + if signals.is_empty() { + return vec![]; + } + let batch = signals.len(); + let n = signals[0].len(); + let device = current_device(); + let kernel = get_or_compile_fwd(device, batch, n); + let n_pad = n.next_power_of_two(); + + let flat: Vec = signals.iter().flat_map(|s| s.iter().copied()).collect(); + let mut lock = kernel.lock().unwrap_or_else(|e| e.into_inner()); + let outputs = lock.run(&[("x", &flat)]); + drop(lock); + + let re_flat = &outputs[0]; + let im_flat = &outputs[1]; + (0..batch) + .map(|i| { + ( + re_flat[i * n_pad..(i + 1) * n_pad].to_vec(), + im_flat[i * n_pad..(i + 1) * n_pad].to_vec(), + ) + }) + .collect() +} + +/// Batched inverse FFT. +/// +/// Each spectrum is a `(real, imag)` pair of length `n_pad` (power of two). +/// Returns the real time-domain signal of the same length. +pub fn ifft_batch(spectra: &[(Vec, Vec)]) -> Vec> { + if spectra.is_empty() { + return vec![]; + } + let batch = spectra.len(); + let n_pad = spectra[0].0.len(); + let device = current_device(); + let kernel = get_or_compile_inv(device, batch, n_pad); + + let re_flat: Vec = spectra.iter().flat_map(|(re, _)| re.iter().copied()).collect(); + let im_flat: Vec = spectra.iter().flat_map(|(_, im)| im.iter().copied()).collect(); + let mut lock = kernel.lock().unwrap_or_else(|e| e.into_inner()); + let outputs = lock.run(&[("re", &re_flat), ("im", &im_flat)]); + drop(lock); + + let out_flat = &outputs[0]; + (0..batch) + .map(|i| out_flat[i * n_pad..(i + 1) * n_pad].to_vec()) + .collect() +} + +/// One-sided Power Spectral Density: `(r² + i²) / n` for each bin. +/// +/// This is a CPU-side reduction on already-downloaded FFT output; GPU +/// acceleration here would be dominated by transfer overhead. +pub fn psd(real: &[f32], imag: &[f32]) -> Vec { + let n = real.len(); + real.iter() + .zip(imag.iter()) + .map(|(&r, &i)| (r * r + i * i) / n as f32) + .collect() +} diff --git a/crates/skill-eeg/tests/fft_mlx_e2e.rs b/crates/skill-eeg/tests/fft_mlx_e2e.rs deleted file mode 100644 index f813142e..00000000 --- a/crates/skill-eeg/tests/fft_mlx_e2e.rs +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -// Copyright (C) 2026 NeuroSkill.com -// -// End-to-end FFT tests for the gpu-fft backend (wgpu + MLX). -// -// Run with: -// cargo test -p skill-eeg --features gpu -- fft_e2e --nocapture -// cargo test -p skill-eeg --features mlx -- fft_e2e --nocapture -// cargo test -p skill-eeg --features gpu,mlx -- fft_e2e --nocapture - -#[cfg(any(feature = "gpu", feature = "mlx"))] -mod fft_e2e { - use gpu_fft::{fft_batch, ifft_batch, psd::psd}; - - /// Generate a sine wave at `freq` Hz, `n` samples at `sr` sample rate. - fn sine(freq: f32, sr: f32, n: usize) -> Vec { - (0..n) - .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sr).sin()) - .collect() - } - - #[test] - fn fft_e2e_roundtrip_256() { - let n = 256; - let signal = sine(10.0, 256.0, n); - let start = std::time::Instant::now(); - let spectra = fft_batch(&[signal.clone()]); - let output = ifft_batch(&spectra); - let elapsed = start.elapsed(); - - // ifft_batch returns [real(0..n), imag(n..2n)] — take real part only - let out = &output[0]; - assert!(out.len() >= n, "output has at least {n} samples (got {})", out.len()); - let max_err: f32 = signal - .iter() - .zip(out[..n].iter()) - .map(|(a, b)| (a - b).abs()) - .fold(0.0f32, f32::max); - assert!(max_err < 1e-3, "round-trip max error {max_err} < 1e-3"); - - eprintln!("── fft_e2e_roundtrip_256 ──"); - eprintln!(" elapsed: {:.2} ms", elapsed.as_secs_f64() * 1000.0); - eprintln!(" max_err: {max_err:.6}"); - } - - #[test] - fn fft_e2e_batch_4ch() { - // Simulate 4-channel EEG at 256 Hz - let signals: Vec> = (0..4).map(|ch| sine(10.0 + ch as f32 * 5.0, 256.0, 256)).collect(); - - let start = std::time::Instant::now(); - let spectra = fft_batch(&signals); - let outputs = ifft_batch(&spectra); - let elapsed = start.elapsed(); - - assert_eq!(spectra.len(), 4, "4 spectra"); - assert_eq!(outputs.len(), 4, "4 outputs"); - - for (ch, (sig, out)) in signals.iter().zip(outputs.iter()).enumerate() { - let max_err: f32 = sig - .iter() - .zip(out[..sig.len()].iter()) - .map(|(a, b)| (a - b).abs()) - .fold(0.0f32, f32::max); - assert!(max_err < 1e-3, "ch{ch} round-trip max error {max_err}"); - } - - eprintln!("── fft_e2e_batch_4ch ──"); - eprintln!(" elapsed: {:.2} ms", elapsed.as_secs_f64() * 1000.0); - } - - #[test] - fn fft_e2e_psd_peak_detection() { - // 10 Hz sine at 256 Hz sample rate, 256 samples → 1 Hz/bin - let n = 256; - let signal = sine(10.0, 256.0, n); - let spectra = fft_batch(&[signal]); - let (re, im) = &spectra[0]; - let power = psd(re, im); - - // One-sided spectrum: only first n/2+1 bins are meaningful - let one_sided = n / 2 + 1; // 129 bins (0..128 Hz) - let peak_bin = power[..one_sided] - .iter() - .enumerate() - .skip(1) // skip DC - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) - .map(|(i, _)| i) - .unwrap(); - - assert_eq!(peak_bin, 10, "PSD peak at bin 10 (10 Hz)"); - - eprintln!("── fft_e2e_psd_peak_detection ──"); - eprintln!(" peak_bin: {peak_bin} (expected 10)"); - eprintln!(" peak_power: {:.4}", power[peak_bin]); - } - - #[test] - fn fft_e2e_large_batch() { - // 32 channels × 1024 samples — stress test - let n = 1024; - let signals: Vec> = (0..32).map(|ch| sine(5.0 + ch as f32, 256.0, n)).collect(); - - let start = std::time::Instant::now(); - let spectra = fft_batch(&signals); - let outputs = ifft_batch(&spectra); - let elapsed = start.elapsed(); - - assert_eq!(outputs.len(), 32); - let throughput = (32 * n) as f64 / elapsed.as_secs_f64(); - - eprintln!("── fft_e2e_large_batch ──"); - eprintln!(" 32 × {n} samples"); - eprintln!(" elapsed: {:.2} ms", elapsed.as_secs_f64() * 1000.0); - eprintln!(" throughput: {:.0} samples/sec", throughput); - } -} diff --git a/crates/skill-exg/Cargo.toml b/crates/skill-exg/Cargo.toml index 96dc4495..d0187021 100644 --- a/crates/skill-exg/Cargo.toml +++ b/crates/skill-exg/Cargo.toml @@ -3,29 +3,36 @@ name = "skill-exg" version = "0.0.1" edition = "2021" license = "GPL-3.0-only" -description = "EEG embedding helpers — cosine distance, fuzzy text matching, timestamp formatting, HuggingFace weight resolution/download, cubecl cache setup, epoch metrics — extracted workspace crate" +description = "EEG embedding helpers — cosine distance, fuzzy text matching, timestamp formatting, HuggingFace weight resolution/download, epoch metrics — extracted workspace crate" [features] default = [] -cubecl = ["dep:cubecl-runtime"] -neurorvq-ndarray = ["dep:neurorvq-rs", "dep:burn", "dep:burn-ndarray", "dep:log", "neurorvq-rs/ndarray"] -neurorvq-metal = ["dep:neurorvq-rs", "dep:burn", "dep:burn-wgpu", "dep:log", "neurorvq-rs/metal", "burn-wgpu/metal"] -neurorvq-vulkan = ["dep:neurorvq-rs", "dep:burn", "dep:burn-wgpu", "dep:log", "neurorvq-rs/vulkan", "burn-wgpu/vulkan"] +neurorvq-ndarray = ["dep:neurorvq-rs", "dep:rlx", "dep:log"] +neurorvq-metal = ["neurorvq-ndarray", "neurorvq-rs/rlx-metal"] +neurorvq-mlx = ["neurorvq-ndarray", "neurorvq-rs/rlx-mlx"] +neurorvq-gpu = ["neurorvq-ndarray", "neurorvq-rs/rlx-gpu"] +neurorvq-cuda = ["neurorvq-ndarray", "neurorvq-rs/rlx-cuda"] +neurorvq-rocm = ["neurorvq-ndarray", "neurorvq-rs/rlx-rocm"] +eegdino-rlx = ["dep:eegdino", "dep:rlx", "dep:log"] +eegdino-metal = ["eegdino-rlx", "eegdino/metal"] +eegdino-mlx = ["eegdino-rlx", "eegdino/mlx"] +eegdino-gpu = ["eegdino-rlx", "eegdino/gpu"] +eegdino-cuda = ["eegdino-rlx", "eegdino/cuda"] +eegdino-rocm = ["eegdino-rlx", "eegdino/rocm"] [dependencies] anyhow = { workspace = true } skill-constants = { path = "../skill-constants" } skill-eeg = { path = "../skill-eeg" } skill-data = { path = "../skill-data" } +skill-devices = { path = "../skill-devices" } serde = { version = "1", features = ["derive"] } serde_json = "1" hf-hub = "0.5" ureq = { version = "3.3", features = ["native-tls", "json"] } -cubecl-runtime = { version = "0.9.0", optional = true } -neurorvq-rs = { package = "neurorvq", version = "0.1.0", optional = true } -burn = { version = "0.20.1", default-features = false, features = ["std"], optional = true } -burn-ndarray = { version = "0.20.1", default-features = false, features = ["std"], optional = true } -burn-wgpu = { version = "0.20.1", default-features = true, optional = true } +neurorvq-rs = { version = "0.2.0", default-features = false, features = ["rlx-cpu"], optional = true } +eegdino = { version = "0.2.0", default-features = false, features = ["rlx-cpu"], optional = true } +rlx = { version = "0.2.5", default-features = false, optional = true } log = { version = "0.4", optional = true } [lints] diff --git a/crates/skill-exg/src/eegdino.rs b/crates/skill-exg/src/eegdino.rs new file mode 100644 index 00000000..09e99ae8 --- /dev/null +++ b/crates/skill-exg/src/eegdino.rs @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! EEG-DINO foundation model integration (RLX backend via [`eegdino`]). +//! +//! Wraps [`eegdino_rs::EegDinoEncoder`] with HuggingFace weight resolution and +//! channel mapping onto the model's fixed 19-channel montage. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use anyhow::{Context, Result}; +use eegdino_rs::{EegDinoEncoder, EncodingResult, ModelConfig, ModelSize}; + +/// Default HuggingFace repo with pre-converted safetensors weights. +pub const HF_REPO: &str = "eugenehp/eegdino"; + +/// International 10–20 montage order expected by EEG-DINO (19 channels). +pub const EEG_DINO_CHANNELS: [&str; 19] = [ + "fp1", "fp2", "f7", "f3", "fz", "f4", "f8", "t7", "c3", "cz", "c4", "t8", "p7", "p3", "pz", "p4", "p8", "o1", "o2", +]; + +pub fn weights_file(variant: &str) -> &'static str { + match variant { + "medium" => "eeg_dino_medium.safetensors", + "large" => "eeg_dino_large.safetensors", + _ => "eeg_dino_small.safetensors", + } +} + +pub fn model_size(variant: &str) -> ModelSize { + match variant { + "medium" => ModelSize::Medium, + "large" => ModelSize::Large, + _ => ModelSize::Small, + } +} + +fn resolve_hf_file(repo: &str, filename: &str) -> Result { + use hf_hub::api::sync::Api; + let api = Api::new().context("HuggingFace Hub API init failed")?; + let repo_handle = api.model(repo.to_string()); + let path = repo_handle + .get(filename) + .with_context(|| format!("Failed to resolve {repo}/{filename}"))?; + Ok(path) +} + +fn normalize_channel(name: &str) -> String { + name.to_lowercase().replace(['-', ' '], "") +} + +fn channel_index(name: &str) -> Option { + let n = normalize_channel(name); + let aliases: [(&str, &str); 8] = [ + ("t3", "t7"), + ("t4", "t8"), + ("t5", "p7"), + ("t6", "p8"), + ("t7", "t7"), + ("t8", "t8"), + ("p7", "p7"), + ("p8", "p8"), + ]; + let key = aliases + .iter() + .find(|(a, _)| *a == n.as_str()) + .map(|(_, canon)| *canon) + .unwrap_or(n.as_str()); + EEG_DINO_CHANNELS.iter().position(|&c| c == key) +} + +/// Map arbitrary headset channels onto the fixed 19-channel EEG-DINO layout. +/// +/// Unmapped montage slots are zero-filled. Samples are truncated to the largest +/// multiple of `patch_size` (200) that fits in the input. +pub fn prepare_signal(samples: &[Vec], channel_names: &[&str], patch_size: usize) -> Result<(Vec, usize)> { + let n_in = channel_names.len().min(samples.len()); + anyhow::ensure!(n_in > 0, "no channels in epoch"); + + let n_samples = samples.iter().take(n_in).map(|s| s.len()).min().unwrap_or(0); + anyhow::ensure!( + n_samples >= patch_size, + "epoch shorter than one EEG-DINO patch ({patch_size} samples)" + ); + + let aligned = n_samples - (n_samples % patch_size); + let mut out = vec![0.0f32; EEG_DINO_CHANNELS.len() * aligned]; + + for (ch_idx, name) in channel_names.iter().take(n_in).enumerate() { + let Some(slot) = channel_index(name) else { + continue; + }; + let src = &samples[ch_idx]; + for t in 0..aligned { + out[slot * aligned + t] = src.get(t).copied().unwrap_or(0.0); + } + } + + Ok((out, aligned)) +} + +pub struct EegDino { + inner: EegDinoEncoder, + embed_dim: usize, +} + +impl EegDino { + pub fn from_files(weights_path: &Path, size: ModelSize, device: rlx::Device) -> Result { + let cfg = ModelConfig::from_size(size); + let embed_dim = cfg.feature_size; + let (inner, ms) = EegDinoEncoder::load(weights_path, Some(cfg), device)?; + log::info!("EEG-DINO-{size:?} loaded in {ms:.0} ms"); + Ok(Self { inner, embed_dim }) + } + + pub fn from_hf(repo: &str, variant: &str, device: rlx::Device) -> Result { + let wf = weights_file(variant); + log::info!("Resolving EEG-DINO-{variant} from {repo}..."); + let t0 = Instant::now(); + let weights_path = resolve_hf_file(repo, wf)?; + log::info!( + "Resolved in {:.0} ms: weights={}", + t0.elapsed().as_secs_f64() * 1000.0, + weights_path.display(), + ); + Self::from_files(&weights_path, model_size(variant), device) + } + + pub fn from_default_hf(variant: &str, device: rlx::Device) -> Result { + Self::from_hf(HF_REPO, variant, device) + } + + pub fn encode_raw(&mut self, signal: &[f32], num_channels: usize, num_samples: usize) -> Result { + self.inner + .encode_raw(signal, 1, num_channels, num_samples) + .map_err(|e| anyhow::anyhow!("{e}")) + } + + /// Mean-pool token embeddings → `[embed_dim]`. + pub fn encode_pooled(&mut self, samples: &[Vec], channel_names: &[&str]) -> Result> { + let patch_size = self.inner.cfg.patch_size; + let (signal, num_samples) = prepare_signal(samples, channel_names, patch_size)?; + let result = self.encode_raw(&signal, EEG_DINO_CHANNELS.len(), num_samples)?; + let seq_len = result.shape.get(1).copied().unwrap_or(1); + let dim = result.shape.get(2).copied().unwrap_or(self.embed_dim); + let mut pooled = vec![0.0f32; dim]; + for t in 0..seq_len { + for (d, p) in pooled.iter_mut().enumerate() { + *p += result.embeddings[t * dim + d]; + } + } + let inv = 1.0 / seq_len as f32; + for p in &mut pooled { + *p *= inv; + } + Ok(pooled) + } + + pub fn embed_dim(&self) -> usize { + self.embed_dim + } +} diff --git a/crates/skill-exg/src/lib.rs b/crates/skill-exg/src/lib.rs index bf504de1..01312a1a 100644 --- a/crates/skill-exg/src/lib.rs +++ b/crates/skill-exg/src/lib.rs @@ -4,7 +4,7 @@ //! //! Everything here is **Tauri-free**: cosine distance, fuzzy text matching, //! UTC timestamp formatting, HuggingFace weight resolution and download, -//! cubecl GPU-cache setup, epoch metrics derivation, and panic helpers. +//! epoch metrics derivation, and panic helpers. use std::path::{Path, PathBuf}; use std::sync::{atomic::AtomicBool, Arc, Mutex}; @@ -16,13 +16,12 @@ use skill_data::util::MutexExt; use skill_eeg::eeg_bands::BandSnapshot; use skill_eeg::eeg_model_config::EegModelStatus; -#[cfg(any( - feature = "neurorvq-ndarray", - feature = "neurorvq-metal", - feature = "neurorvq-vulkan" -))] +#[cfg(feature = "neurorvq-ndarray")] pub mod neurorvq; +#[cfg(feature = "eegdino-rlx")] +pub mod eegdino; + // ── Cosine distance ─────────────────────────────────────────────────────────── /// Cosine distance between two `f32` vectors (0 = identical, 2 = opposite). @@ -149,7 +148,6 @@ pub fn validate_safetensors(path: &Path) -> bool { } } let expected = 8 + header_len + max_offset; - // Burn's safetensors loader requires exact size match. // Allow a small tolerance (< 16 bytes) for alignment padding, // but reject files with significant extra data — they indicate // a corrupt or incomplete download. @@ -333,26 +331,30 @@ pub fn download_hf_weights_files( }; let repo = api.model(hf_repo.to_string()); - { - let mut st = status.lock_or_recover(); - st.download_status_msg = Some(format!("Downloading {config_file}…")); - } - let config_path = match repo.get(config_file) { - Ok(p) => { - eprintln!("[embedder] ✓ {config_file} → {}", p.display()); - p - } - Err(e) => { - eprintln!("[embedder] failed to download {config_file}: {e}"); + let config_path = if config_file.is_empty() { + PathBuf::new() + } else { + { let mut st = status.lock_or_recover(); - st.downloading_weights = false; - st.download_progress = 0.0; - st.download_status_msg = Some(format!("Download failed ({config_file}): {e}")); - return None; + st.download_status_msg = Some(format!("Downloading {config_file}…")); + } + match repo.get(config_file) { + Ok(p) => { + eprintln!("[embedder] ✓ {config_file} → {}", p.display()); + p + } + Err(e) => { + eprintln!("[embedder] failed to download {config_file}: {e}"); + let mut st = status.lock_or_recover(); + st.downloading_weights = false; + st.download_progress = 0.0; + st.download_status_msg = Some(format!("Download failed ({config_file}): {e}")); + return None; + } } }; - if cancel.load(Ordering::Relaxed) { + if !config_file.is_empty() && cancel.load(Ordering::Relaxed) { eprintln!("[embedder] download cancelled by user after config.json"); let mut st = status.lock_or_recover(); st.downloading_weights = false; @@ -659,44 +661,6 @@ pub fn download_hf_weights_files( Some((weights_path, config_path)) } -// ── cubecl cache warm-up ────────────────────────────────────────────────────── - -/// Pre-create the cubecl GPU-kernel cache directory and configure the -/// `GlobalConfig` so cubecl never tries to write to an inaccessible path. -/// -/// Must be called **before** the first `WgpuDevice` access. -/// -/// Note: This function is only available when the `cubecl` feature is enabled. -#[cfg(feature = "cubecl")] -pub fn configure_cubecl_cache(skill_dir: &Path) { - use cubecl_runtime::config::{cache::CacheConfig, GlobalConfig}; - use std::sync::atomic::{AtomicBool, Ordering}; - - static CUBECL_CONFIGURED: AtomicBool = AtomicBool::new(false); - - let cache_dir = skill_dir.join("cubecl_cache"); - match std::fs::create_dir_all(&cache_dir) { - Ok(_) => eprintln!("[embedder] cubecl cache dir: {}", cache_dir.display()), - Err(e) => eprintln!("[embedder] warn: cubecl cache mkdir {}: {e}", cache_dir.display()), - } - - if CUBECL_CONFIGURED - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - let mut cfg = GlobalConfig::default(); - cfg.autotune.cache = CacheConfig::File(cache_dir); - GlobalConfig::set(cfg); - } -} - -/// No-op stub for when the `cubecl` feature is disabled. -#[cfg(not(feature = "cubecl"))] -pub fn configure_cubecl_cache(_skill_dir: &Path) { - // CubeCL functionality is disabled in this build. - // This is used in CI/coverage builds where GPU drivers are unavailable. -} - // ── GPU panic flag ──────────────────────────────────────────────────────────── /// Process-global flag: set to `true` after any GPU panic so respawned workers @@ -748,6 +712,7 @@ pub struct EpochMetrics { pub sample_entropy: f32, pub pac_theta_gamma: f32, pub laterality_index: f32, + pub echt: f32, pub hr: f64, pub rmssd: f64, pub sdnn: f64, @@ -776,6 +741,10 @@ pub struct EpochMetrics { impl EpochMetrics { /// Derive metrics from a `BandSnapshot` by averaging across all channels. + /// + /// Engagement and relaxation delegate to `skill_devices::compute_engagement` + /// / `compute_relaxation` — the single source of truth shared with the live + /// `latest_bands` path. Storing here is fine; *computing* here is not. pub fn from_snapshot(snap: &BandSnapshot) -> Self { let n = snap.channels.len() as f32; if n < 1.0 { @@ -788,8 +757,6 @@ impl EpochMetrics { let mut rb = 0.0f32; let mut rg = 0.0f32; let mut rhg = 0.0f32; - let mut sum_relax = 0.0f32; - let mut sum_engage = 0.0f32; for ch in &snap.channels { rd += ch.rel_delta; @@ -798,17 +765,6 @@ impl EpochMetrics { rb += ch.rel_beta; rg += ch.rel_gamma; rhg += ch.rel_high_gamma; - let a = ch.rel_alpha; - let b = ch.rel_beta; - let t = ch.rel_theta; - let d1 = a + t; - let d2 = b + t; - if d2 > 1e-6 { - sum_relax += a / d2; - } - if d1 > 1e-6 { - sum_engage += b / d1; - } } rd /= n; rt /= n; @@ -832,8 +788,8 @@ impl EpochMetrics { rel_beta: rb, rel_gamma: rg, rel_high_gamma: rhg, - relaxation: Self::sigmoid100(sum_relax / n, 2.5, 1.0), - engagement: Self::sigmoid100(sum_engage / n, 2.0, 0.8), + relaxation: skill_devices::compute_relaxation(snap) as f32, + engagement: skill_devices::compute_engagement(snap) as f32, faa, tar: snap.tar, bar: snap.bar, @@ -857,6 +813,7 @@ impl EpochMetrics { sample_entropy: snap.sample_entropy, pac_theta_gamma: snap.pac_theta_gamma, laterality_index: snap.laterality_index, + echt: snap.echt, hr: 0.0, rmssd: 0.0, sdnn: 0.0, @@ -924,6 +881,7 @@ impl Default for EpochMetrics { sample_entropy: 0.0, pac_theta_gamma: 0.0, laterality_index: 0.0, + echt: 0.0, hr: 0.0, rmssd: 0.0, sdnn: 0.0, @@ -1136,6 +1094,136 @@ mod tests { assert_eq!(back.rel_delta, m.rel_delta); } + /// Closes the single-source-of-truth loop: storage path + /// (`EpochMetrics::from_snapshot`) and live path + /// (`skill_devices::compute_engagement` / `compute_relaxation`) must + /// agree on the same `BandSnapshot`. Pre-refactor they diverged — this + /// test would have caught the stuck-engagement bug. + #[test] + fn epoch_metrics_match_canonical_compute() { + use skill_eeg::eeg_bands::{BandPowers, BandSnapshot}; + + let ch = BandPowers { + channel: "AF7".into(), + delta: 5.0, + theta: 3.0, + alpha: 4.0, + beta: 6.0, + gamma: 1.0, + high_gamma: 0.5, + rel_delta: 0.25, + rel_theta: 0.15, + rel_alpha: 0.20, + rel_beta: 0.30, + rel_gamma: 0.05, + rel_high_gamma: 0.05, + dominant: "beta".into(), + dominant_symbol: "β".into(), + dominant_color: "#22c55e".into(), + }; + let mut snap = BandSnapshot { + timestamp: 0.0, + channels: vec![ch.clone(), ch.clone(), ch.clone(), ch], + faa: 0.0, + tar: 0.5, + bar: 0.4, + dtr: 1.2, + pse: 0.7, + apf: 10.0, + bps: -1.5, + snr: 12.0, + coherence: 0.5, + mu_suppression: 0.1, + mood: 60.0, + tbr: 0.8, + sef95: 22.0, + spectral_centroid: 15.0, + hjorth_activity: 0.1, + hjorth_mobility: 0.2, + hjorth_complexity: 0.3, + permutation_entropy: 0.6, + higuchi_fd: 1.5, + dfa_exponent: 0.7, + sample_entropy: 0.4, + pac_theta_gamma: 0.1, + laterality_index: 0.05, + echt: 0.5, + headache_index: 10.0, + migraine_index: 5.0, + consciousness_lzc: 50.0, + consciousness_wakefulness: 70.0, + consciousness_integration: 60.0, + hr: None, + rmssd: None, + sdnn: None, + pnn50: None, + lf_hf_ratio: None, + respiratory_rate: None, + spo2_estimate: None, + perfusion_index: None, + stress_index: None, + blink_count: None, + blink_rate: None, + head_pitch: None, + head_roll: None, + stillness: None, + nod_count: None, + shake_count: None, + meditation: None, + cognitive_load: None, + drowsiness: None, + engagement: None, + relaxation: None, + focus: None, + temperature_raw: None, + gpu_overall: None, + gpu_render: None, + gpu_tiler: None, + rel_delta: 0.25, + rel_theta: 0.15, + rel_alpha: 0.20, + rel_beta: 0.30, + rel_gamma: 0.05, + }; + + let metrics = EpochMetrics::from_snapshot(&snap); + let canonical_e = skill_devices::compute_engagement(&snap) as f32; + let canonical_r = skill_devices::compute_relaxation(&snap) as f32; + + assert!( + (metrics.engagement - canonical_e).abs() < 0.001, + "EpochMetrics.engagement={} diverges from canonical={canonical_e}", + metrics.engagement, + ); + assert!( + (metrics.relaxation - canonical_r).abs() < 0.001, + "EpochMetrics.relaxation={} diverges from canonical={canonical_r}", + metrics.relaxation, + ); + + // And confirm enrich_band_snapshot puts the same value on the wire format. + skill_devices::enrich_band_snapshot( + &mut snap, + &skill_devices::SnapshotContext { + ppg: None, + artifacts: None, + head_pose: None, + temperature_raw: 0, + gpu: None, + }, + ); + let on_snapshot_e = snap.engagement.unwrap(); + let on_snapshot_r = snap.relaxation.unwrap(); + assert!( + (on_snapshot_e as f32 - canonical_e).abs() < 0.05, + "snapshot.engagement={on_snapshot_e} diverges from canonical={canonical_e}", + ); + assert!( + (on_snapshot_r as f32 - canonical_r).abs() < 0.05, + "snapshot.relaxation={on_snapshot_r} diverges from canonical={canonical_r}", + ); + } + // ── validate_safetensors ───────────────────────────────────────────── #[test] diff --git a/crates/skill-exg/src/neurorvq.rs b/crates/skill-exg/src/neurorvq.rs index 1a6cd45a..4ab985fc 100644 --- a/crates/skill-exg/src/neurorvq.rs +++ b/crates/skill-exg/src/neurorvq.rs @@ -13,32 +13,10 @@ use std::time::Instant; use anyhow::{Context, Result}; pub use neurorvq_rs::{ - ConfigOverrides, FMEncoderResult, ForwardResult, InputBatch, Modality, NeuroRVQConfig, ReconstructionResult, + ConfigOverrides, FMEncoderResult, ForwardResult, Modality, NeuroRVQConfig, ReconstructionResult, RlxInputBatch, TokenResult, }; -#[cfg(feature = "neurorvq-ndarray")] -type B = burn_ndarray::NdArray; - -#[cfg(feature = "neurorvq-ndarray")] -fn default_device() -> burn_ndarray::NdArrayDevice { - burn_ndarray::NdArrayDevice::Cpu -} - -#[cfg(all( - any(feature = "neurorvq-metal", feature = "neurorvq-vulkan"), - not(feature = "neurorvq-ndarray") -))] -type B = burn_wgpu::Wgpu; - -#[cfg(all( - any(feature = "neurorvq-metal", feature = "neurorvq-vulkan"), - not(feature = "neurorvq-ndarray") -))] -fn default_device() -> burn_wgpu::WgpuDevice { - burn_wgpu::WgpuDevice::DefaultDevice -} - /// Default HuggingFace repo with pre-converted safetensors weights. pub const HF_REPO: &str = "eugenehp/NeuroRVQ"; @@ -77,19 +55,23 @@ fn resolve_hf_file(repo: &str, filename: &str) -> Result { } pub struct NeuroRVQ { - inner: neurorvq_rs::NeuroRVQEncoder, + inner: neurorvq_rs::NeuroRVQEncoder, } impl NeuroRVQ { - pub fn from_files(config_path: &Path, weights_path: &Path, modality: Modality) -> Result { - let dev = default_device(); + pub fn from_files( + config_path: &Path, + weights_path: &Path, + modality: Modality, + device: rlx::Device, + ) -> Result { let (inner, ms) = - neurorvq_rs::NeuroRVQEncoder::::load_with_modality(config_path, weights_path, modality, dev)?; + neurorvq_rs::NeuroRVQEncoder::load_with_modality(config_path, weights_path, modality, device)?; log::info!("NeuroRVQ-{modality} loaded in {ms:.0} ms"); Ok(Self { inner }) } - pub fn from_hf(repo: &str, modality: Modality) -> Result { + pub fn from_hf(repo: &str, modality: Modality, device: rlx::Device) -> Result { let weights_file = tokenizer_weights_file(modality); let cfg_file = config_file(modality); @@ -106,64 +88,42 @@ impl NeuroRVQ { weights_path.display(), ); - Self::from_files(&config_path, &weights_path, modality) + Self::from_files(&config_path, &weights_path, modality, device) } - pub fn from_default_hf(modality: Modality) -> Result { - Self::from_hf(HF_REPO, modality) + pub fn from_default_hf(modality: Modality, device: rlx::Device) -> Result { + Self::from_hf(HF_REPO, modality, device) } - pub fn from_hf_with_overrides(repo: &str, modality: Modality, overrides: &ConfigOverrides) -> Result { + pub fn from_hf_with_overrides( + repo: &str, + modality: Modality, + overrides: &ConfigOverrides, + device: rlx::Device, + ) -> Result { let weights_file = tokenizer_weights_file(modality); let cfg_file = config_file(modality); let config_path = resolve_hf_file(repo, cfg_file)?; let weights_path = resolve_hf_file(repo, weights_file)?; - let dev = default_device(); let (inner, ms) = - neurorvq_rs::NeuroRVQEncoder::::load_full(&config_path, &weights_path, modality, Some(overrides), dev)?; + neurorvq_rs::NeuroRVQEncoder::load_full(&config_path, &weights_path, modality, Some(overrides), device)?; log::info!("NeuroRVQ-{modality} loaded in {ms:.0} ms (with overrides)"); Ok(Self { inner }) } - pub fn tokenize(&self, signal: &[f32], channel_names: &[&str]) -> Result { - let modality = self.inner.modality; - let config = &self.inner.config; - let n_channels = channel_names.len(); - let n_samples = signal.len() / n_channels; - let n_time = neurorvq_rs::compute_n_time(config.n_patches, n_channels); - - anyhow::ensure!( - n_samples == n_time * config.patch_size, - "Signal length mismatch: got {} samples per channel, expected {} (n_time={} × patch_size={})", - n_samples, - n_time * config.patch_size, - n_time, - config.patch_size, - ); - - let dev = self.inner.device(); - let batch = neurorvq_rs::build_batch_with_modality( - signal.to_vec(), - channel_names, - n_time, - config.n_patches, - n_channels, - n_samples, - modality, - dev, - ); - + pub fn tokenize(&mut self, signal: &[f32], channel_names: &[&str]) -> Result { + let batch = self.build_batch(signal, channel_names)?; self.inner.tokenize(&batch) } - pub fn reconstruct(&self, signal: &[f32], channel_names: &[&str]) -> Result { + pub fn reconstruct(&mut self, signal: &[f32], channel_names: &[&str]) -> Result { let batch = self.build_batch(signal, channel_names)?; self.inner.reconstruct(&batch) } - pub fn forward(&self, signal: &[f32], channel_names: &[&str]) -> Result { + pub fn forward(&mut self, signal: &[f32], channel_names: &[&str]) -> Result { let batch = self.build_batch(signal, channel_names)?; self.inner.forward(&batch) } @@ -176,7 +136,7 @@ impl NeuroRVQ { &self.inner.config } - fn build_batch(&self, signal: &[f32], channel_names: &[&str]) -> Result> { + fn build_batch(&self, signal: &[f32], channel_names: &[&str]) -> Result { let config = &self.inner.config; let n_channels = channel_names.len(); let n_samples = signal.len() / n_channels; @@ -189,8 +149,7 @@ impl NeuroRVQ { n_time * config.patch_size, ); - let dev = self.inner.device(); - Ok(neurorvq_rs::build_batch_with_modality( + Ok(neurorvq_rs::build_batch( signal.to_vec(), channel_names, n_time, @@ -198,24 +157,27 @@ impl NeuroRVQ { n_channels, n_samples, self.inner.modality, - dev, )) } } pub struct NeuroRVQFM { - inner: neurorvq_rs::NeuroRVQFoundationModel, + inner: neurorvq_rs::NeuroRVQFoundationModel, } impl NeuroRVQFM { - pub fn from_files(config_path: &Path, weights_path: &Path, modality: Modality) -> Result { - let dev = default_device(); - let (inner, ms) = neurorvq_rs::NeuroRVQFoundationModel::::load(config_path, weights_path, modality, dev)?; + pub fn from_files( + config_path: &Path, + weights_path: &Path, + modality: Modality, + device: rlx::Device, + ) -> Result { + let (inner, ms) = neurorvq_rs::NeuroRVQFoundationModel::load(config_path, weights_path, modality, device)?; log::info!("NeuroRVQ-FM-{modality} loaded in {ms:.0} ms"); Ok(Self { inner }) } - pub fn from_hf(repo: &str, modality: Modality) -> Result { + pub fn from_hf(repo: &str, modality: Modality, device: rlx::Device) -> Result { let weights_file = fm_weights_file(modality).with_context(|| format!("No foundation model available for {modality}"))?; let cfg_file = config_file(modality); @@ -223,24 +185,42 @@ impl NeuroRVQFM { let config_path = resolve_hf_file(repo, cfg_file)?; let weights_path = resolve_hf_file(repo, weights_file)?; - Self::from_files(&config_path, &weights_path, modality) + Self::from_files(&config_path, &weights_path, modality, device) } - pub fn from_default_hf(modality: Modality) -> Result { - Self::from_hf(HF_REPO, modality) + pub fn from_default_hf(modality: Modality, device: rlx::Device) -> Result { + Self::from_hf(HF_REPO, modality, device) } - pub fn encode(&self, signal: &[f32], channel_names: &[&str]) -> Result { + pub fn encode(&mut self, signal: &[f32], channel_names: &[&str]) -> Result { let batch = self.build_batch(signal, channel_names)?; self.inner.encode(&batch) } - pub fn encode_pooled(&self, signal: &[f32], channel_names: &[&str]) -> Result> { - let batch = self.build_batch(signal, channel_names)?; - self.inner.encode_pooled(&batch) - } - - fn build_batch(&self, signal: &[f32], channel_names: &[&str]) -> Result> { + /// Mean-pool branch features across the sequence dimension and concatenate. + /// Output shape: `[4 * embed_dim]` — mirrors the old Burn `encode_pooled`. + pub fn encode_pooled(&mut self, signal: &[f32], channel_names: &[&str]) -> Result> { + let result = self.encode(signal, channel_names)?; + let seq_len = result.shape.get(1).copied().unwrap_or(1); + let embed_dim = result.shape.get(2).copied().unwrap_or(1); + let mut pooled = Vec::with_capacity(4 * embed_dim); + for branch in &result.branch_features { + let mut mean = vec![0f32; embed_dim]; + for s in 0..seq_len { + for e in 0..embed_dim { + mean[e] += branch[s * embed_dim + e]; + } + } + let inv = 1.0 / seq_len as f32; + for v in &mut mean { + *v *= inv; + } + pooled.extend_from_slice(&mean); + } + Ok(pooled) + } + + fn build_batch(&self, signal: &[f32], channel_names: &[&str]) -> Result { let config = &self.inner.config; let n_channels = channel_names.len(); let n_samples = signal.len() / n_channels; @@ -253,8 +233,7 @@ impl NeuroRVQFM { n_time * config.patch_size, ); - let dev = self.inner.device(); - Ok(neurorvq_rs::build_batch_with_modality( + Ok(neurorvq_rs::build_batch( signal.to_vec(), channel_names, n_time, @@ -262,7 +241,6 @@ impl NeuroRVQFM { n_channels, n_samples, self.inner.modality, - dev, )) } } diff --git a/crates/skill-headless/Cargo.toml b/crates/skill-headless/Cargo.toml index d7d82cb1..48f37281 100644 --- a/crates/skill-headless/Cargo.toml +++ b/crates/skill-headless/Cargo.toml @@ -8,10 +8,10 @@ description = "Headless browser engine — CDP-like API over wry/tao for navigat [dependencies] anyhow = { workspace = true } # Windowing — hidden window hosts the webview; provides event loop + proxy. -tao = { version = "0.35", default-features = false, features = ["rwh_06"] } +tao = { version = "0.34", default-features = false, features = ["rwh_06"] } # WebView — system webview with full JS, DOM, network stack. -wry = { version = "0.55" } +wry = { version = "0.54" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/skill-history/src/cache.rs b/crates/skill-history/src/cache.rs index 97d85d92..addb13f2 100644 --- a/crates/skill-history/src/cache.rs +++ b/crates/skill-history/src/cache.rs @@ -207,6 +207,8 @@ struct MetricsBlob { #[serde(default, deserialize_with = "null_as_zero")] laterality_index: f64, #[serde(default, deserialize_with = "null_as_zero")] + echt: f64, + #[serde(default, deserialize_with = "null_as_zero")] hr: f64, #[serde(default, deserialize_with = "null_as_zero")] rmssd: f64, @@ -324,6 +326,7 @@ impl MetricsBlob { se: self.sample_entropy, pac: self.pac_theta_gamma, lat: self.laterality_index, + echt: self.echt, hr: self.hr, rmssd: self.rmssd, sdnn: self.sdnn, @@ -380,6 +383,7 @@ impl MetricsBlob { total.sample_entropy += self.sample_entropy; total.pac_theta_gamma += self.pac_theta_gamma; total.laterality_index += self.laterality_index; + total.echt += self.echt; total.hr += self.hr; total.rmssd += self.rmssd; total.sdnn += self.sdnn; @@ -704,6 +708,7 @@ pub fn get_session_metrics(skill_dir: &Path, start_utc: u64, end_utc: u64) -> Se total.sample_entropy /= n; total.pac_theta_gamma /= n; total.laterality_index /= n; + total.echt /= n; total.hr /= n; total.rmssd /= n; total.sdnn /= n; @@ -1501,6 +1506,7 @@ struct MetricsBlobOut { sample_entropy: f64, pac_theta_gamma: f64, laterality_index: f64, + echt: f64, hr: f64, rmssd: f64, sdnn: f64, @@ -1555,6 +1561,7 @@ fn epoch_row_to_metrics_json(row: &EpochRow) -> String { sample_entropy: row.se, pac_theta_gamma: row.pac, laterality_index: row.lat, + echt: row.echt, hr: row.hr, rmssd: row.rmssd, sdnn: row.sdnn, diff --git a/crates/skill-history/src/lib.rs b/crates/skill-history/src/lib.rs index c04f3a44..33764f49 100644 --- a/crates/skill-history/src/lib.rs +++ b/crates/skill-history/src/lib.rs @@ -126,6 +126,20 @@ pub struct SessionEntry { /// Average signal-to-noise ratio (dB) for the session. /// `None` for legacy sessions recorded before SNR tracking. pub avg_snr_db: Option, + /// Number of underlying rollover chunks merged into this entry. `1` + /// for ordinary single-chunk sessions; `>1` when adjacent same-device + /// chunks have been collapsed into one logical session for the UI. + #[serde(default = "default_chunk_count")] + pub chunk_count: u32, + /// CSV paths of every chunk in this logical session, oldest first. + /// `None` for non-collapsed entries (the canonical `csv_path` is the + /// only chunk). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chunks: Option>, +} + +fn default_chunk_count() -> u32 { + 1 } // ── Typed session JSON sidecar (replaces serde_json::Value for speed) ───────── @@ -312,6 +326,7 @@ pub struct SessionMetrics { pub sample_entropy: f64, pub pac_theta_gamma: f64, pub laterality_index: f64, + pub echt: f64, pub hr: f64, pub rmssd: f64, pub sdnn: f64, @@ -370,6 +385,7 @@ pub struct EpochRow { pub se: f64, pub pac: f64, pub lat: f64, + pub echt: f64, pub mood: f64, pub hr: f64, pub rmssd: f64, @@ -572,6 +588,8 @@ pub fn list_sessions_for_day( labels: vec![], file_size_bytes: csv_size, avg_snr_db: meta.avg_snr_db, + chunk_count: 1, + chunks: None, }, start, end, @@ -616,6 +634,8 @@ pub fn list_sessions_for_day( labels: vec![], file_size_bytes: csv_size, avg_snr_db: None, // no sidecar available + chunk_count: 1, + chunks: None, }, ts, end_ts, @@ -640,7 +660,97 @@ pub fn list_sessions_for_day( let mut sessions: Vec = raw.into_iter().map(|(s, _, _)| s).collect(); sessions.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); - sessions + collapse_adjacent_chunks(sessions) +} + +/// Maximum gap (seconds) between two same-device chunks for them to be +/// considered the "same logical session" and merged into one entry. +/// Rollover boundaries normally produce 0–1s gaps; tolerate up to 5s for +/// LSL/BLE jitter or a brief reconnect. +const ROLLOVER_GAP_TOLERANCE_S: u64 = 5; + +/// Collapse adjacent same-device chunks into single logical entries. +/// +/// Rollover (`session_rollover_minutes`) splits long recordings into many +/// chunk files. For UI listing, runs of chunks with the same `device_name` +/// and ≤ `ROLLOVER_GAP_TOLERANCE_S` seconds between adjacent end → start +/// are merged into one entry that aggregates `total_samples`, +/// `session_duration_s`, `file_size_bytes`, and labels. The newest +/// chunk's `csv_path` is kept as the canonical reference; every chunk's +/// path is preserved in the new `chunks` field for drill-down. +/// +/// Crate-internal alias so sibling modules (`local_days`) can re-collapse +/// after dir-merging. Kept thin and named to match its private twin. +pub(crate) fn collapse_adjacent_chunks_pub(sorted_desc: Vec) -> Vec { + collapse_adjacent_chunks(sorted_desc) +} + +/// Input must be sorted by `session_start_utc` descending. +fn collapse_adjacent_chunks(sorted_desc: Vec) -> Vec { + if sorted_desc.is_empty() { + return sorted_desc; + } + + let mut out: Vec = Vec::with_capacity(sorted_desc.len()); + for s in sorted_desc { + let Some(head) = out.last_mut() else { + out.push(s); + continue; + }; + // `s` is older than head (input sorted DESC). Adjacent if `s.end` + // is within ROLLOVER_GAP_TOLERANCE_S of `head.start`, AND device + // names match (skip merging across device swaps). + let same_device = match (&head.device_name, &s.device_name) { + (Some(a), Some(b)) => a == b, + _ => false, + }; + let adjacent = match (head.session_start_utc, s.session_end_utc) { + (Some(hs), Some(se)) => hs.saturating_sub(se) <= ROLLOVER_GAP_TOLERANCE_S && hs >= se, + _ => false, + }; + if !same_device || !adjacent { + out.push(s); + continue; + } + // Merge `s` into `head` (head is the newer one; absorb older). + head.session_start_utc = s.session_start_utc.or(head.session_start_utc); + if let (Some(hs), Some(he)) = (head.session_start_utc, head.session_end_utc) { + head.session_duration_s = Some(he.saturating_sub(hs)); + } + head.total_samples = match (head.total_samples, s.total_samples) { + (Some(a), Some(b)) => Some(a + b), + (Some(a), None) | (None, Some(a)) => Some(a), + (None, None) => None, + }; + head.file_size_bytes = head.file_size_bytes.saturating_add(s.file_size_bytes); + head.chunk_count = head.chunk_count.saturating_add(1); + // Inherit identity / hardware fields from the absorbed chunk if + // the head is missing them (e.g. an in-progress chunk with a + // partial sidecar). + head.firmware_version = head.firmware_version.clone().or(s.firmware_version); + head.serial_number = head.serial_number.clone().or(s.serial_number); + head.mac_address = head.mac_address.clone().or(s.mac_address); + head.hardware_version = head.hardware_version.clone().or(s.hardware_version); + head.sample_rate_hz = head.sample_rate_hz.or(s.sample_rate_hz); + // Keep all chunk paths in oldest → newest order. We walk + // newest → older (input sorted DESC), so prepend each absorbed + // chunk to the front. This stays correct under repeated calls + // (e.g. the second collapse in `list_sessions_for_local_day`) + // — no post-pass reverse, which would flip an already-correct + // list. + let chunks = head.chunks.get_or_insert_with(|| vec![head.csv_path.clone()]); + chunks.insert(0, s.csv_path); + head.labels.extend(s.labels); + // avg_snr_db: take the simple average of available values for now + // (a sample-weighted average would require keeping per-chunk + // counts; not worth the extra plumbing for a UI summary field). + head.avg_snr_db = match (head.avg_snr_db, s.avg_snr_db) { + (Some(a), Some(b)) => Some((a + b) / 2.0), + (Some(a), None) | (None, Some(a)) => Some(a), + (None, None) => None, + }; + } + out } /// Compute average SNR (dB) from the embeddings SQLite for sessions that @@ -911,7 +1021,7 @@ pub fn list_embedding_sessions(skill_dir: &Path) -> Vec { day_names.push(day_name); if let Ok(rows) = rows { for row in rows.filter_map(std::result::Result::ok) { - all_ts.push(((row / 1000) as u64, day_idx)); + all_ts.push((skill_data::util::epoch_ts_to_unix(row), day_idx)); } } } @@ -1431,4 +1541,161 @@ mod session_listing_tests { assert!(!metrics.exists()); assert!(!ppg.exists()); } + + // ── collapse_adjacent_chunks ────────────────────────────────────────── + + /// Helper: build a SessionEntry with the fields the collapser actually + /// reads, defaulting the rest. Input `(start, end, samples, dev)`. + fn mk(start: u64, end: u64, samples: u64, device: &str, path: &str) -> super::SessionEntry { + super::SessionEntry { + csv_file: format!("{path}.csv"), + csv_path: path.to_string(), + session_start_utc: Some(start), + session_end_utc: Some(end), + session_duration_s: Some(end - start), + device_name: Some(device.to_string()), + device_id: None, + serial_number: None, + mac_address: None, + firmware_version: None, + hardware_version: None, + headset_preset: None, + battery_pct: None, + total_samples: Some(samples), + sample_rate_hz: Some(256), + labels: vec![], + file_size_bytes: 100, + avg_snr_db: Some(15.0), + chunk_count: 1, + chunks: None, + } + } + + #[test] + fn collapse_merges_60_adjacent_chunks_into_one_entry() { + // Mirror the 1-hour test: 60 chunks back-to-back, same device. + let mut chunks: Vec = (0..60) + .map(|i| { + let start = 1_777_900_000 + i as u64 * 60; + mk(start, start + 60, 15360, "Muse-Roll", &format!("p{i}")) + }) + .collect(); + chunks.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(chunks); + assert_eq!(merged.len(), 1, "60 adjacent chunks must collapse to one"); + let m = &merged[0]; + assert_eq!(m.chunk_count, 60); + assert_eq!(m.total_samples, Some(15360 * 60)); + assert_eq!(m.session_duration_s, Some(60 * 60)); + assert_eq!(m.session_start_utc, Some(1_777_900_000)); + assert_eq!(m.session_end_utc, Some(1_777_900_000 + 60 * 60)); + let chunk_paths = m.chunks.as_ref().expect("chunks list populated"); + assert_eq!(chunk_paths.len(), 60); + assert_eq!(chunk_paths.first().unwrap(), "p0", "oldest first"); + assert_eq!(chunk_paths.last().unwrap(), "p59", "newest last"); + } + + #[test] + fn collapse_keeps_non_adjacent_separate() { + // Two clusters separated by a 5-minute gap. + let mut entries = vec![ + mk(1000, 1060, 100, "Muse", "a1"), + mk(1060, 1120, 100, "Muse", "a2"), + mk(1500, 1560, 100, "Muse", "b1"), // 380s gap → separate + mk(1560, 1620, 100, "Muse", "b2"), + ]; + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 2, "two distinct sessions must remain"); + assert_eq!(merged[0].chunk_count, 2); + assert_eq!(merged[1].chunk_count, 2); + } + + #[test] + fn collapse_keeps_different_devices_separate() { + let mut entries = vec![ + mk(1000, 1060, 100, "Muse", "muse1"), + mk(1060, 1120, 100, "Muse", "muse2"), + mk(1120, 1180, 100, "OpenBCI", "obci1"), // device swap + mk(1180, 1240, 100, "OpenBCI", "obci2"), + ]; + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].device_name.as_deref(), Some("OpenBCI")); + assert_eq!(merged[0].chunk_count, 2); + assert_eq!(merged[1].device_name.as_deref(), Some("Muse")); + assert_eq!(merged[1].chunk_count, 2); + } + + #[test] + fn collapse_tolerates_gaps_within_5s() { + // 3-second BLE jitter between chunks — must still merge. + let mut entries = vec![ + mk(1000, 1060, 100, "Muse", "a"), + mk(1063, 1123, 100, "Muse", "b"), // 3s gap + mk(1128, 1188, 100, "Muse", "c"), // 5s gap (boundary) + ]; + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].chunk_count, 3); + } + + #[test] + fn collapse_breaks_on_6s_gap() { + let mut entries = vec![ + mk(1000, 1060, 100, "Muse", "a"), + mk(1066, 1126, 100, "Muse", "b"), // 6s gap → split + ]; + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 2); + } + + #[test] + fn collapse_singleton_unchanged() { + let entries = vec![mk(1000, 1060, 100, "Muse", "solo")]; + let merged = super::collapse_adjacent_chunks(entries); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].chunk_count, 1); + assert!(merged[0].chunks.is_none(), "singleton has no chunks list"); + } + + #[test] + fn collapse_empty_input() { + let merged = super::collapse_adjacent_chunks(vec![]); + assert!(merged.is_empty()); + } + + /// Calling collapse twice must not flip the `chunks` ordering. + /// Important because `list_sessions_for_local_day` re-collapses the + /// merged result of `list_sessions_for_day` to handle UTC-midnight + /// crossings. + #[test] + fn collapse_is_idempotent_for_chunks_order() { + let mut entries: Vec = (0..10) + .map(|i| { + let start = 1_700_000_000 + i as u64 * 60; + mk(start, start + 60, 1000, "Muse", &format!("p{i}")) + }) + .collect(); + entries.sort_by_key(|s| std::cmp::Reverse(s.session_start_utc)); + + let pass1 = super::collapse_adjacent_chunks(entries); + let pass1_chunks = pass1[0].chunks.clone().unwrap(); + + let pass2 = super::collapse_adjacent_chunks(pass1); + let pass2_chunks = pass2[0].chunks.clone().unwrap(); + + assert_eq!(pass1_chunks, pass2_chunks, "second collapse must not reorder"); + assert_eq!(pass1_chunks.first().unwrap(), "p0", "oldest first"); + assert_eq!(pass1_chunks.last().unwrap(), "p9", "newest last"); + assert_eq!(pass2[0].chunk_count, 10, "chunk_count preserved through re-collapse"); + } } diff --git a/crates/skill-history/src/local_days.rs b/crates/skill-history/src/local_days.rs index bc58cb84..f0aa2d57 100644 --- a/crates/skill-history/src/local_days.rs +++ b/crates/skill-history/src/local_days.rs @@ -192,7 +192,11 @@ pub fn list_sessions_for_local_day( tb.cmp(&ta) }); - merged + // Second pass: a session that crosses UTC midnight has its chunks + // split across two day dirs. Each per-dir collapse only sees its own + // half, leaving two adjacent entries here. Re-collapse so the local- + // day listing shows one logical session. + crate::collapse_adjacent_chunks_pub(merged) } /// List ALL sessions across ALL days, newest first. diff --git a/crates/skill-history/src/metrics.rs b/crates/skill-history/src/metrics.rs index a3f3f14e..1f46762a 100644 --- a/crates/skill-history/src/metrics.rs +++ b/crates/skill-history/src/metrics.rs @@ -132,6 +132,9 @@ pub fn load_metrics_csv(csv_path: &Path) -> Option { let gpu_v = f(x + 43); let gpu_r_v = f(x + 44); let gpu_t_v = f(x + 45); + // echt is appended at the end of the cross-channel block; missing in + // older recordings → f() returns 0.0 silently. + let echt_v = f(x + 46); let mut sr = 0.0f64; let mut se2 = 0.0f64; @@ -182,6 +185,7 @@ pub fn load_metrics_csv(csv_path: &Path) -> Option { se: se_v, pac: pac_v, lat: lat_v, + echt: echt_v, mood: mood_v, hr: hr_v, rmssd: rmssd_v, @@ -237,6 +241,7 @@ pub fn load_metrics_csv(csv_path: &Path) -> Option { sum.sample_entropy += se_v; sum.pac_theta_gamma += pac_v; sum.laterality_index += lat_v; + sum.echt += echt_v; sum.hr += hr_v; sum.rmssd += rmssd_v; sum.sdnn += sdnn_v; @@ -297,6 +302,7 @@ pub fn load_metrics_csv(csv_path: &Path) -> Option { sum.sample_entropy /= n; sum.pac_theta_gamma /= n; sum.laterality_index /= n; + sum.echt /= n; sum.hr /= n; sum.rmssd /= n; sum.sdnn /= n; @@ -432,6 +438,7 @@ fn load_metrics_from_parquet(path: &Path) -> Option { let gpu_v = f(x + 43); let gpu_r_v = f(x + 44); let gpu_t_v = f(x + 45); + let echt_v = f(x + 46); let mut sr = 0.0f64; let mut se2 = 0.0f64; @@ -482,6 +489,7 @@ fn load_metrics_from_parquet(path: &Path) -> Option { se: se_v, pac: pac_v, lat: lat_v, + echt: echt_v, mood: mood_v, hr: hr_v, rmssd: rmssd_v, @@ -537,6 +545,7 @@ fn load_metrics_from_parquet(path: &Path) -> Option { sum.sample_entropy += se_v; sum.pac_theta_gamma += pac_v; sum.laterality_index += lat_v; + sum.echt += echt_v; sum.hr += hr_v; sum.rmssd += rmssd_v; sum.sdnn += sdnn_v; @@ -597,6 +606,7 @@ fn load_metrics_from_parquet(path: &Path) -> Option { sum.sample_entropy /= n; sum.pac_theta_gamma /= n; sum.laterality_index /= n; + sum.echt /= n; sum.hr /= n; sum.rmssd /= n; sum.sdnn /= n; diff --git a/crates/skill-iroh/Cargo.toml b/crates/skill-iroh/Cargo.toml index 8aca6bfe..0cfac834 100644 --- a/crates/skill-iroh/Cargo.toml +++ b/crates/skill-iroh/Cargo.toml @@ -10,14 +10,19 @@ anyhow = { workspace = true } thiserror = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -iroh = "0.97" +iroh = "1.0.0-rc.0" +# pkcs8 0.11.0 stable is now compatible with iroh 1.0.0-rc.0's ed25519-dalek rand = "0.9" tokio = { version = "1", features = ["full"] } totp-rs = { version = "5.7", features = ["gen_secret", "otpauth", "qr"] } ureq = { version = "3", features = ["native-tls", "json"] } base64 = "0.22" qrcodegen = "1.8" -image = "0.25" +# Default features pull `avif` → `ravif` → `rav1e` (AV1 encoder, ~5 MB). +# We only need PNG to render QR codes for TOTP pairing — opt out of the +# full codec set. Other workspace consumers already do this; we were the +# lone outlier forcing AVIF into every binary via Cargo feature unification. +image = { version = "0.25", default-features = false, features = ["png"] } zstd = "0.13" [dev-dependencies] diff --git a/crates/skill-iroh/src/tunnel.rs b/crates/skill-iroh/src/tunnel.rs index 5eef24b0..b9561d7c 100644 --- a/crates/skill-iroh/src/tunnel.rs +++ b/crates/skill-iroh/src/tunnel.rs @@ -322,7 +322,7 @@ async fn proxy_api_stream( tcp_write.shutdown().await.context("tcp shutdown failed")?; return Ok::<(), anyhow::Error>(()); }; - tcp_write.write_all(&chunk.bytes).await.context("tcp write failed")?; + tcp_write.write_all(&chunk).await.context("tcp write failed")?; } }; @@ -397,7 +397,7 @@ pub fn rotate_secret_key(skill_dir: &Path) -> anyhow::Result<(String, String)> { std::fs::write(&history_path, hist_json).context("write history")?; // Generate new key - let new_key = SecretKey::generate(&mut rand::rng()); + let new_key = SecretKey::generate(); std::fs::write(&key_path, new_key.to_bytes()).context("write new key")?; #[cfg(unix)] @@ -437,7 +437,7 @@ fn load_or_create_secret_key(skill_dir: &Path) -> anyhow::Result { )); } - let secret = SecretKey::generate(&mut rand::rng()); + let secret = SecretKey::generate(); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } diff --git a/crates/skill-label-index/Cargo.toml b/crates/skill-label-index/Cargo.toml index 6a4855e3..f5de1b32 100644 --- a/crates/skill-label-index/Cargo.toml +++ b/crates/skill-label-index/Cargo.toml @@ -4,6 +4,13 @@ version = "0.0.1" edition = "2021" license = "GPL-3.0-only" description = "Cross-modal label HNSW indices (text, context, EEG) — extracted workspace crate" +build = "build.rs" + +[features] +# HNSW is the runtime default and needs no BLAS. Enable `turboquant-index` for the +# TurboQuant backend (OpenBLAS on Linux, Accelerate on macOS). +default = [] +turboquant-index = ["dep:turbovec", "dep:accelerate-src"] [dependencies] skill-constants = { path = "../skill-constants" } @@ -12,9 +19,13 @@ skill-data = { path = "../skill-data" } fast-hnsw = "1.0.1" rusqlite = { workspace = true } serde = { version = "1", features = ["derive"] } +turbovec = { version = "0.4.1", optional = true } [dev-dependencies] tempfile = "3" +[target.'cfg(target_os = "macos")'.dependencies] +accelerate-src = { version = "0.3", optional = true } + [lints] workspace = true diff --git a/crates/skill-label-index/build.rs b/crates/skill-label-index/build.rs new file mode 100644 index 00000000..1d019bf9 --- /dev/null +++ b/crates/skill-label-index/build.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Link system OpenBLAS on Linux when building with `turboquant-index`. + +mod linux_openblas { + include!("../../build-support/linux_openblas.rs"); +} + +fn main() { + let enabled = std::env::var_os("CARGO_FEATURE_TURBOQUANT_INDEX").is_some(); + linux_openblas::link_system_openblas(enabled); +} diff --git a/crates/skill-label-index/src/lib.rs b/crates/skill-label-index/src/lib.rs index d1400863..53b2b6ab 100644 --- a/crates/skill-label-index/src/lib.rs +++ b/crates/skill-label-index/src/lib.rs @@ -24,6 +24,9 @@ //! Both indices store `label_id: i64` as the HNSW payload so results can be //! joined back to `labels.sqlite` for full hydration. +#[cfg(all(target_os = "macos", feature = "turboquant-index"))] +extern crate accelerate_src; + use std::{path::Path, sync::Mutex}; use fast_hnsw::{distance::Cosine, labeled::LabeledIndex, Builder}; @@ -32,7 +35,8 @@ use serde::Serialize; use skill_commands::NeighborMetrics; use skill_constants::{ - HNSW_EF_CONSTRUCTION, HNSW_M, LABELS_FILE, LABEL_CONTEXT_INDEX_FILE, LABEL_EEG_INDEX_FILE, LABEL_TEXT_INDEX_FILE, + HNSW_EF_CONSTRUCTION, HNSW_M, LABELS_FILE, LABEL_CONTEXT_INDEX_FILE, LABEL_CONTEXT_TURBOVEC_INDEX_FILE, + LABEL_EEG_INDEX_FILE, LABEL_EEG_TURBOVEC_INDEX_FILE, LABEL_TEXT_INDEX_FILE, LABEL_TEXT_TURBOVEC_INDEX_FILE, SQLITE_FILE, }; use skill_data::util::MutexExt; @@ -41,7 +45,31 @@ use skill_data::util::MutexExt; const TEXT_INDEX_FILE: &str = LABEL_TEXT_INDEX_FILE; const CONTEXT_INDEX_FILE: &str = LABEL_CONTEXT_INDEX_FILE; const EEG_INDEX_FILE: &str = LABEL_EEG_INDEX_FILE; +const TEXT_TURBOVEC_INDEX_FILE: &str = LABEL_TEXT_TURBOVEC_INDEX_FILE; +const CONTEXT_TURBOVEC_INDEX_FILE: &str = LABEL_CONTEXT_TURBOVEC_INDEX_FILE; +const EEG_TURBOVEC_INDEX_FILE: &str = LABEL_EEG_TURBOVEC_INDEX_FILE; const HNSW_EF: usize = HNSW_EF_CONSTRUCTION; +#[cfg(feature = "turboquant-index")] +const TURBOVEC_BIT_WIDTH: usize = 4; + +/// Whether to build/load/update TurboVec indices. +/// +/// HNSW-only users never touch OpenBLAS. TurboVec is maintained when it is the +/// preferred search backend, or when on-disk TurboVec files already exist (e.g. +/// after switching back from a benchmark or a prior TurboQuant session). +#[cfg(feature = "turboquant-index")] +fn maintain_turbovec_indices(state: &LabelIndexState, skill_dir: &Path) -> bool { + if state.preferred_backend() == LabelIndexBackend::TurboVec { + return true; + } + [ + TEXT_TURBOVEC_INDEX_FILE, + CONTEXT_TURBOVEC_INDEX_FILE, + EEG_TURBOVEC_INDEX_FILE, + ] + .iter() + .any(|name| skill_dir.join(name).exists()) +} fn fresh_index() -> LabeledIndex { Builder::new().m(HNSW_M).ef_construction(HNSW_EF).build_labeled(Cosine) @@ -129,12 +157,125 @@ fn safe_search<'a>( idx.search(query, k, ef.max(k)) } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LabelIndexBackend { + Hnsw, + TurboVec, +} + +impl LabelIndexBackend { + pub fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "hnsw" | "fast-hnsw" | "fast_hnsw" => Some(Self::Hnsw), + "turboquant" | "turbo-quant" | "turbo_quant" | "turbovec" | "turbo_vec" | "tv" => Some(Self::TurboVec), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Hnsw => "hnsw", + Self::TurboVec => "turboquant", + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct BackendCounts { + pub text_nodes: usize, + pub context_nodes: usize, + pub eeg_nodes: usize, +} + +/// Per-component on-disk size of a single index backend. +#[derive(Clone, Debug, Default, Serialize)] +pub struct BackendFootprint { + pub text_bytes: u64, + pub context_bytes: u64, + pub eeg_bytes: u64, +} + +impl BackendFootprint { + pub fn total(&self) -> u64 { + self.text_bytes + .saturating_add(self.context_bytes) + .saturating_add(self.eeg_bytes) + } +} + +/// Disk footprint of every label-index file under `skill_dir`. +/// +/// Sizes are bytes on disk for the persisted index files; this is also the +/// approximate mmap footprint for the TurboVec backend (which loads via mmap) +/// and an upper bound on the resident heap for HNSW (which fully deserializes). +#[derive(Clone, Debug, Default, Serialize)] +pub struct LabelIndexMemory { + pub hnsw: BackendFootprint, + pub turbovec: BackendFootprint, + pub total_bytes: u64, +} + +#[derive(Debug, Serialize)] +pub struct LabelSearchBenchmark { + pub backend: &'static str, + pub available: bool, + pub elapsed_us: u128, + pub results: Vec, +} + +#[derive(Debug, Serialize)] +pub struct LabelSearchBenchmarkComparison { + pub top_match: bool, + pub overlap_count: usize, + pub overlap_ratio: f32, + pub avg_distance_delta: f32, + pub max_distance_delta: f32, + pub close: bool, + pub min_overlap_ratio: f32, + pub max_allowed_distance_delta: f32, +} + +#[cfg(feature = "turboquant-index")] +type TurboIndex = turbovec::IdMapIndex; + +#[cfg(feature = "turboquant-index")] +fn turbovec_supported_dim(dim: usize) -> bool { + dim >= 8 && dim % 8 == 0 +} + +#[cfg(feature = "turboquant-index")] +fn load_turbovec(path: &Path, label: &str) -> Option { + if !path.exists() { + return None; + } + match TurboIndex::load(path) { + Ok(idx) => { + idx.prepare(); + eprintln!("[label_idx] loaded {label} TurboVec from {}", path.display()); + Some(idx) + } + Err(e) => { + eprintln!("[label_idx] {label} TurboVec load failed ({e}), using HNSW fallback"); + None + } + } +} + // ── State ───────────────────────────────────────────────────────────────────── pub struct LabelIndexState { pub text: Mutex>>, pub context: Mutex>>, pub eeg: Mutex>>, + preferred_backend: Mutex, + #[cfg(feature = "turboquant-index")] + text_turbovec: Mutex>, + #[cfg(feature = "turboquant-index")] + context_turbovec: Mutex>, + #[cfg(feature = "turboquant-index")] + eeg_turbovec: Mutex>, + turbovec_counts: Mutex, } impl Default for LabelIndexState { @@ -143,6 +284,18 @@ impl Default for LabelIndexState { text: Mutex::new(None), context: Mutex::new(None), eeg: Mutex::new(None), + preferred_backend: Mutex::new(LabelIndexBackend::Hnsw), + #[cfg(feature = "turboquant-index")] + text_turbovec: Mutex::new(None), + #[cfg(feature = "turboquant-index")] + context_turbovec: Mutex::new(None), + #[cfg(feature = "turboquant-index")] + eeg_turbovec: Mutex::new(None), + turbovec_counts: Mutex::new(BackendCounts { + text_nodes: 0, + context_nodes: 0, + eeg_nodes: 0, + }), } } } @@ -152,6 +305,48 @@ impl LabelIndexState { Self::default() } + pub fn set_preferred_backend(&self, backend: LabelIndexBackend) { + *self.preferred_backend.lock_or_recover() = backend; + } + + pub fn preferred_backend(&self) -> LabelIndexBackend { + *self.preferred_backend.lock_or_recover() + } + + pub fn hnsw_counts(&self) -> BackendCounts { + BackendCounts { + text_nodes: self.text.lock_or_recover().as_ref().map_or(0, |i| i.len()), + context_nodes: self.context.lock_or_recover().as_ref().map_or(0, |i| i.len()), + eeg_nodes: self.eeg.lock_or_recover().as_ref().map_or(0, |i| i.len()), + } + } + + pub fn turbovec_counts(&self) -> BackendCounts { + self.turbovec_counts.lock_or_recover().clone() + } + + /// Inspect on-disk footprint of every label-index file under `skill_dir`. + /// Cheap (three stat calls per backend); safe to call from request paths. + pub fn memory_footprint(&self, skill_dir: &Path) -> LabelIndexMemory { + let file_size = |p: &Path| -> u64 { std::fs::metadata(p).map(|m| m.len()).unwrap_or(0) }; + let hnsw = BackendFootprint { + text_bytes: file_size(&skill_dir.join(TEXT_INDEX_FILE)), + context_bytes: file_size(&skill_dir.join(CONTEXT_INDEX_FILE)), + eeg_bytes: file_size(&skill_dir.join(EEG_INDEX_FILE)), + }; + let turbovec = BackendFootprint { + text_bytes: file_size(&skill_dir.join(TEXT_TURBOVEC_INDEX_FILE)), + context_bytes: file_size(&skill_dir.join(CONTEXT_TURBOVEC_INDEX_FILE)), + eeg_bytes: file_size(&skill_dir.join(EEG_TURBOVEC_INDEX_FILE)), + }; + let total_bytes = hnsw.total().saturating_add(turbovec.total()); + LabelIndexMemory { + hnsw, + turbovec, + total_bytes, + } + } + /// Load (or create) all three indices from `skill_dir`. Called on startup. pub fn load(&self, skill_dir: &Path) { let text_path = skill_dir.join(TEXT_INDEX_FILE); @@ -160,6 +355,15 @@ impl LabelIndexState { *self.text.lock_or_recover() = Some(load_or_fresh(&text_path)); *self.context.lock_or_recover() = Some(load_or_fresh(&context_path)); *self.eeg.lock_or_recover() = Some(load_or_fresh(&eeg_path)); + + #[cfg(feature = "turboquant-index")] + if maintain_turbovec_indices(self, skill_dir) { + *self.text_turbovec.lock_or_recover() = load_turbovec(&skill_dir.join(TEXT_TURBOVEC_INDEX_FILE), "text"); + *self.context_turbovec.lock_or_recover() = + load_turbovec(&skill_dir.join(CONTEXT_TURBOVEC_INDEX_FILE), "context"); + *self.eeg_turbovec.lock_or_recover() = load_turbovec(&skill_dir.join(EEG_TURBOVEC_INDEX_FILE), "eeg"); + refresh_turbovec_counts_from_db(skill_dir, self); + } } } @@ -503,6 +707,178 @@ fn hydrate(row: LabelRow, distance: f32, skill_dir: &Path) -> LabelNeighbor { } } +fn hydrate_hits(hits: Vec<(i64, f32)>, labels_db: &Path, skill_dir: &Path) -> Vec { + hits.into_iter() + .filter_map(|(label_id, distance)| { + let row = fetch_label_by_id(labels_db, label_id)?; + Some(hydrate(row, distance, skill_dir)) + }) + .collect() +} + +fn hnsw_search_hits(idx: &LabeledIndex, query: &[f32], k: usize, ef: usize) -> Vec<(i64, f32)> { + safe_search(idx, query, k, ef) + .into_iter() + .map(|hit| (*hit.payload, hit.distance)) + .collect() +} + +#[cfg(feature = "turboquant-index")] +fn turbovec_score_to_distance(score: f32) -> f32 { + (1.0 - score).clamp(0.0, 2.0) +} + +#[cfg(feature = "turboquant-index")] +fn turbovec_search_hits(idx: &TurboIndex, query: &[f32], k: usize) -> Option> { + if query.is_empty() || k == 0 { + return Some(vec![]); + } + if idx.dim_opt()? != query.len() { + eprintln!( + "[label_idx] TurboVec search skipped: query dim {} != index dim {}", + query.len(), + idx.dim() + ); + return Some(vec![]); + } + let (scores, ids) = idx.search(query, k); + Some( + ids.into_iter() + .zip(scores) + .map(|(id, score)| (id as i64, turbovec_score_to_distance(score))) + .collect(), + ) +} + +#[cfg(feature = "turboquant-index")] +fn build_turbovec_from_embeddings( + rows: impl Iterator)>, + expected_dim: Option, + tag: &str, +) -> (Option, usize) { + let Some(dim) = expected_dim else { return (None, 0) }; + if !turbovec_supported_dim(dim) { + eprintln!("[label_idx] {tag} TurboVec disabled: dim {dim} must be >= 8 and a multiple of 8"); + return (None, 0); + } + + let mut flat = Vec::new(); + let mut ids = Vec::new(); + for (label_id, emb) in rows { + if label_id < 0 || emb.len() != dim { + continue; + } + flat.extend_from_slice(&emb); + ids.push(label_id as u64); + } + if ids.is_empty() { + return (None, 0); + } + + let mut idx = TurboIndex::new(dim, TURBOVEC_BIT_WIDTH); + idx.add_with_ids(&flat, &ids); + idx.prepare(); + let len = ids.len(); + (Some(idx), len) +} + +#[cfg(feature = "turboquant-index")] +fn save_turbovec(idx: &TurboIndex, path: &Path, tag: &str) { + if let Err(e) = idx.write(path) { + eprintln!("[label_idx] {tag} TurboVec save: {e}"); + } +} + +#[cfg(feature = "turboquant-index")] +fn refresh_turbovec_counts_from_db(skill_dir: &Path, state: &LabelIndexState) { + let labels_db = skill_dir.join(LABELS_FILE); + if !labels_db.exists() { + *state.turbovec_counts.lock_or_recover() = BackendCounts { + text_nodes: 0, + context_nodes: 0, + eeg_nodes: 0, + }; + return; + } + + let rows = read_label_rows(&labels_db); + let text_dim = state + .text_turbovec + .lock_or_recover() + .as_ref() + .and_then(|idx| idx.dim_opt()); + let context_dim = state + .context_turbovec + .lock_or_recover() + .as_ref() + .and_then(|idx| idx.dim_opt()); + let eeg_dim = state + .eeg_turbovec + .lock_or_recover() + .as_ref() + .and_then(|idx| idx.dim_opt()); + + let text_nodes = text_dim.map_or(0, |dim| { + rows.iter() + .filter(|r| r.id >= 0 && r.text_embedding.as_ref().is_some_and(|e| e.len() == dim)) + .count() + }); + let context_nodes = context_dim.map_or(0, |dim| { + rows.iter() + .filter(|r| r.id >= 0 && r.context_embedding.as_ref().is_some_and(|e| e.len() == dim)) + .count() + }); + let eeg_nodes = eeg_dim.map_or(0, |_| 0); + *state.turbovec_counts.lock_or_recover() = BackendCounts { + text_nodes, + context_nodes, + eeg_nodes, + }; +} + +#[cfg(feature = "turboquant-index")] +fn try_insert_turbovec( + slot: &Mutex>, + len_slot: &mut usize, + emb: &[f32], + label_id: i64, + save_path: &Path, + tag: &str, +) -> bool { + if emb.is_empty() { + return true; + } + if label_id < 0 || !turbovec_supported_dim(emb.len()) { + return false; + } + + let mut guard = slot.lock_or_recover(); + if guard.is_none() { + *guard = Some(TurboIndex::new(emb.len(), TURBOVEC_BIT_WIDTH)); + } + + let Some(idx) = guard.as_mut() else { return false }; + if let Some(dim) = idx.dim_opt() { + if dim != emb.len() { + eprintln!( + "[label_idx] {tag} TurboVec dim mismatch for label {label_id}: {} != index {dim}", + emb.len() + ); + return false; + } + } + let id = label_id as u64; + if idx.contains(id) { + idx.remove(id); + } else { + *len_slot += 1; + } + idx.add_with_ids(emb, &[id]); + idx.prepare(); + save_turbovec(idx, save_path, tag); + true +} + // ── Public API ──────────────────────────────────────────────────────────────── /// (Re-)build both HNSW indices from the current state of `labels.sqlite`. @@ -536,6 +912,33 @@ pub fn rebuild(skill_dir: &Path, state: &LabelIndexState) -> RebuildStats { let mut text_dim: Option = dominant_text_dim; let mut ctx_dim: Option = dominant_ctx_dim; let mut eeg_dim: Option = None; + #[cfg(feature = "turboquant-index")] + let mut eeg_turbovec_rows: Vec<(i64, Vec)> = Vec::new(); + + #[cfg(feature = "turboquant-index")] + let build_turbo = maintain_turbovec_indices(state, skill_dir); + #[cfg(feature = "turboquant-index")] + let (text_turbovec, text_turbovec_nodes) = if build_turbo { + build_turbovec_from_embeddings( + rows.iter() + .filter_map(|r| r.text_embedding.as_ref().map(|emb| (r.id, emb.clone()))), + dominant_text_dim, + "text", + ) + } else { + (None, 0) + }; + #[cfg(feature = "turboquant-index")] + let (context_turbovec, context_turbovec_nodes) = if build_turbo { + build_turbovec_from_embeddings( + rows.iter() + .filter_map(|r| r.context_embedding.as_ref().map(|emb| (r.id, emb.clone()))), + dominant_ctx_dim, + "context", + ) + } else { + (None, 0) + }; for row in rows { // ── text HNSW ───────────────────────────────────────────────────────── @@ -550,6 +953,10 @@ pub fn rebuild(skill_dir: &Path, state: &LabelIndexState) -> RebuildStats { // ── EEG HNSW ────────────────────────────────────────────────────────── if let Some(mean_emb) = mean_eeg_for_window(skill_dir, row.eeg_start, row.eeg_end) { + #[cfg(feature = "turboquant-index")] + if build_turbo { + eeg_turbovec_rows.push((row.id, mean_emb.clone())); + } if !safe_insert(&mut eeg_idx, mean_emb, row.id, &mut eeg_dim) { eeg_skipped += 1; } @@ -582,10 +989,40 @@ pub fn rebuild(skill_dir: &Path, state: &LabelIndexState) -> RebuildStats { eprintln!("[label_idx] eeg save: {e}"); } + #[cfg(feature = "turboquant-index")] + let (eeg_turbovec, eeg_turbovec_nodes) = if build_turbo { + build_turbovec_from_embeddings(eeg_turbovec_rows.into_iter(), eeg_dim, "eeg") + } else { + (None, 0) + }; + #[cfg(feature = "turboquant-index")] + if build_turbo { + if let Some(ref idx) = text_turbovec { + save_turbovec(idx, &skill_dir.join(TEXT_TURBOVEC_INDEX_FILE), "text"); + } + if let Some(ref idx) = context_turbovec { + save_turbovec(idx, &skill_dir.join(CONTEXT_TURBOVEC_INDEX_FILE), "context"); + } + if let Some(ref idx) = eeg_turbovec { + save_turbovec(idx, &skill_dir.join(EEG_TURBOVEC_INDEX_FILE), "eeg"); + } + } + // Update in-memory state. *state.text.lock_or_recover() = Some(text_idx); *state.context.lock_or_recover() = Some(context_idx); *state.eeg.lock_or_recover() = Some(eeg_idx); + #[cfg(feature = "turboquant-index")] + { + *state.text_turbovec.lock_or_recover() = text_turbovec; + *state.context_turbovec.lock_or_recover() = context_turbovec; + *state.eeg_turbovec.lock_or_recover() = eeg_turbovec; + *state.turbovec_counts.lock_or_recover() = BackendCounts { + text_nodes: text_turbovec_nodes, + context_nodes: context_turbovec_nodes, + eeg_nodes: eeg_turbovec_nodes, + }; + } eprintln!( "[label_idx] rebuilt: {text_nodes} text, {context_nodes} context, {eeg_nodes} eeg ({eeg_skipped} skipped)" @@ -657,6 +1094,20 @@ pub fn insert_label( needs_rebuild = true; } } + #[cfg(feature = "turboquant-index")] + if maintain_turbovec_indices(state, skill_dir) && turbovec_supported_dim(text_embedding.len()) { + let mut counts = state.turbovec_counts.lock_or_recover(); + if !try_insert_turbovec( + &state.text_turbovec, + &mut counts.text_nodes, + text_embedding, + label_id, + &skill_dir.join(TEXT_TURBOVEC_INDEX_FILE), + "text", + ) { + needs_rebuild = true; + } + } } // ── Context HNSW ────────────────────────────────────────────────────────── @@ -673,6 +1124,20 @@ pub fn insert_label( needs_rebuild = true; } } + #[cfg(feature = "turboquant-index")] + if maintain_turbovec_indices(state, skill_dir) && turbovec_supported_dim(context_embedding.len()) { + let mut counts = state.turbovec_counts.lock_or_recover(); + if !try_insert_turbovec( + &state.context_turbovec, + &mut counts.context_nodes, + context_embedding, + label_id, + &skill_dir.join(CONTEXT_TURBOVEC_INDEX_FILE), + "context", + ) { + needs_rebuild = true; + } + } } // ── EEG HNSW ────────────────────────────────────────────────────────────── @@ -683,6 +1148,20 @@ pub fn insert_label( needs_rebuild = true; } } + #[cfg(feature = "turboquant-index")] + if maintain_turbovec_indices(state, skill_dir) && turbovec_supported_dim(mean_emb.len()) { + let mut counts = state.turbovec_counts.lock_or_recover(); + if !try_insert_turbovec( + &state.eeg_turbovec, + &mut counts.eeg_nodes, + &mean_emb, + label_id, + &skill_dir.join(EEG_TURBOVEC_INDEX_FILE), + "eeg", + ) { + needs_rebuild = true; + } + } } // On dimension mismatch, rebuild all indices from SQLite. @@ -705,17 +1184,24 @@ pub fn search_by_text_vec( skill_dir: &Path, state: &LabelIndexState, ) -> Vec { - let labels_db = skill_dir.join(LABELS_FILE); - let guard = state.text.lock_or_recover(); - let Some(ref idx) = *guard else { return vec![] }; - - safe_search(idx, query, k, ef) - .into_iter() - .filter_map(|hit| { - let row = fetch_label_by_id(&labels_db, *hit.payload)?; - Some(hydrate(row, hit.distance, skill_dir)) - }) - .collect() + match state.preferred_backend() { + LabelIndexBackend::TurboVec => { + #[cfg(feature = "turboquant-index")] + { + let labels_db = skill_dir.join(LABELS_FILE); + if let Some(hits) = state + .text_turbovec + .lock_or_recover() + .as_ref() + .and_then(|idx| turbovec_search_hits(idx, query, k)) + { + return hydrate_hits(hits, &labels_db, skill_dir); + } + } + search_text_hnsw(query, k, ef, skill_dir, state) + } + LabelIndexBackend::Hnsw => search_text_hnsw(query, k, ef, skill_dir, state), + } } /// Search the **context** HNSW with a pre-computed text embedding vector. @@ -727,17 +1213,24 @@ pub fn search_by_context_vec( skill_dir: &Path, state: &LabelIndexState, ) -> Vec { - let labels_db = skill_dir.join(LABELS_FILE); - let guard = state.context.lock_or_recover(); - let Some(ref idx) = *guard else { return vec![] }; - - safe_search(idx, query, k, ef) - .into_iter() - .filter_map(|hit| { - let row = fetch_label_by_id(&labels_db, *hit.payload)?; - Some(hydrate(row, hit.distance, skill_dir)) - }) - .collect() + match state.preferred_backend() { + LabelIndexBackend::TurboVec => { + #[cfg(feature = "turboquant-index")] + { + let labels_db = skill_dir.join(LABELS_FILE); + if let Some(hits) = state + .context_turbovec + .lock_or_recover() + .as_ref() + .and_then(|idx| turbovec_search_hits(idx, query, k)) + { + return hydrate_hits(hits, &labels_db, skill_dir); + } + } + search_context_hnsw(query, k, ef, skill_dir, state) + } + LabelIndexBackend::Hnsw => search_context_hnsw(query, k, ef, skill_dir, state), + } } /// Search the **EEG** HNSW with an EEG embedding vector. @@ -748,18 +1241,220 @@ pub fn search_by_eeg_vec( ef: usize, skill_dir: &Path, state: &LabelIndexState, +) -> Vec { + match state.preferred_backend() { + LabelIndexBackend::TurboVec => { + #[cfg(feature = "turboquant-index")] + { + let labels_db = skill_dir.join(LABELS_FILE); + if let Some(hits) = state + .eeg_turbovec + .lock_or_recover() + .as_ref() + .and_then(|idx| turbovec_search_hits(idx, query, k)) + { + return hydrate_hits(hits, &labels_db, skill_dir); + } + } + search_eeg_hnsw(query, k, ef, skill_dir, state) + } + LabelIndexBackend::Hnsw => search_eeg_hnsw(query, k, ef, skill_dir, state), + } +} + +fn search_text_hnsw( + query: &[f32], + k: usize, + ef: usize, + skill_dir: &Path, + state: &LabelIndexState, +) -> Vec { + let labels_db = skill_dir.join(LABELS_FILE); + let guard = state.text.lock_or_recover(); + let Some(ref idx) = *guard else { return vec![] }; + hydrate_hits(hnsw_search_hits(idx, query, k, ef), &labels_db, skill_dir) +} + +fn search_context_hnsw( + query: &[f32], + k: usize, + ef: usize, + skill_dir: &Path, + state: &LabelIndexState, +) -> Vec { + let labels_db = skill_dir.join(LABELS_FILE); + let guard = state.context.lock_or_recover(); + let Some(ref idx) = *guard else { return vec![] }; + hydrate_hits(hnsw_search_hits(idx, query, k, ef), &labels_db, skill_dir) +} + +fn search_eeg_hnsw( + query: &[f32], + k: usize, + ef: usize, + skill_dir: &Path, + state: &LabelIndexState, ) -> Vec { let labels_db = skill_dir.join(LABELS_FILE); let guard = state.eeg.lock_or_recover(); let Some(ref idx) = *guard else { return vec![] }; + hydrate_hits(hnsw_search_hits(idx, query, k, ef), &labels_db, skill_dir) +} - safe_search(idx, query, k, ef) - .into_iter() - .filter_map(|hit| { - let row = fetch_label_by_id(&labels_db, *hit.payload)?; - Some(hydrate(row, hit.distance, skill_dir)) - }) - .collect() +pub fn benchmark_text_vec( + query: &[f32], + k: usize, + ef: usize, + skill_dir: &Path, + state: &LabelIndexState, +) -> Vec { + benchmark_vec(query, k, ef, skill_dir, state, "text") +} + +pub fn benchmark_context_vec( + query: &[f32], + k: usize, + ef: usize, + skill_dir: &Path, + state: &LabelIndexState, +) -> Vec { + benchmark_vec(query, k, ef, skill_dir, state, "context") +} + +pub fn benchmark_eeg_vec( + query: &[f32], + k: usize, + ef: usize, + skill_dir: &Path, + state: &LabelIndexState, +) -> Vec { + benchmark_vec(query, k, ef, skill_dir, state, "eeg") +} + +pub fn compare_benchmarks(benchmarks: &[LabelSearchBenchmark]) -> Option { + const MIN_OVERLAP_RATIO: f32 = 0.60; + const MAX_DISTANCE_DELTA: f32 = 0.05; + + let hnsw = benchmarks.iter().find(|b| b.backend == "hnsw" && b.available)?; + let turbo = benchmarks.iter().find(|b| b.backend == "turboquant" && b.available)?; + if hnsw.results.is_empty() || turbo.results.is_empty() { + return Some(LabelSearchBenchmarkComparison { + top_match: false, + overlap_count: 0, + overlap_ratio: 0.0, + avg_distance_delta: 0.0, + max_distance_delta: 0.0, + close: false, + min_overlap_ratio: MIN_OVERLAP_RATIO, + max_allowed_distance_delta: MAX_DISTANCE_DELTA, + }); + } + + let top_match = hnsw.results[0].label_id == turbo.results[0].label_id; + let hnsw_by_id: std::collections::HashMap = + hnsw.results.iter().map(|r| (r.label_id, r.distance)).collect(); + + let mut overlap_count = 0usize; + let mut delta_sum = 0.0f32; + let mut max_distance_delta = 0.0f32; + for result in &turbo.results { + let Some(hnsw_distance) = hnsw_by_id.get(&result.label_id) else { + continue; + }; + overlap_count += 1; + let delta = (hnsw_distance - result.distance).abs(); + delta_sum += delta; + max_distance_delta = max_distance_delta.max(delta); + } + + let denom = hnsw.results.len().max(turbo.results.len()).max(1) as f32; + let overlap_ratio = overlap_count as f32 / denom; + let avg_distance_delta = if overlap_count == 0 { + 0.0 + } else { + delta_sum / overlap_count as f32 + }; + let close = top_match && overlap_ratio >= MIN_OVERLAP_RATIO && max_distance_delta <= MAX_DISTANCE_DELTA; + + Some(LabelSearchBenchmarkComparison { + top_match, + overlap_count, + overlap_ratio, + avg_distance_delta, + max_distance_delta, + close, + min_overlap_ratio: MIN_OVERLAP_RATIO, + max_allowed_distance_delta: MAX_DISTANCE_DELTA, + }) +} + +fn benchmark_vec( + query: &[f32], + k: usize, + ef: usize, + skill_dir: &Path, + state: &LabelIndexState, + mode: &str, +) -> Vec { + let labels_db = skill_dir.join(LABELS_FILE); + let mut out = Vec::with_capacity(2); + + let hnsw_start = std::time::Instant::now(); + let hnsw_hits = match mode { + "context" => state + .context + .lock_or_recover() + .as_ref() + .map(|idx| hnsw_search_hits(idx, query, k, ef)), + "eeg" => state + .eeg + .lock_or_recover() + .as_ref() + .map(|idx| hnsw_search_hits(idx, query, k, ef)), + _ => state + .text + .lock_or_recover() + .as_ref() + .map(|idx| hnsw_search_hits(idx, query, k, ef)), + }; + let hnsw_elapsed = hnsw_start.elapsed().as_micros(); + out.push(LabelSearchBenchmark { + backend: "hnsw", + available: hnsw_hits.is_some(), + elapsed_us: hnsw_elapsed, + results: hnsw_hits.map_or_else(Vec::new, |hits| hydrate_hits(hits, &labels_db, skill_dir)), + }); + + let turbo_start = std::time::Instant::now(); + #[cfg(feature = "turboquant-index")] + let turbo_hits = match mode { + "context" => state + .context_turbovec + .lock_or_recover() + .as_ref() + .and_then(|idx| turbovec_search_hits(idx, query, k)), + "eeg" => state + .eeg_turbovec + .lock_or_recover() + .as_ref() + .and_then(|idx| turbovec_search_hits(idx, query, k)), + _ => state + .text_turbovec + .lock_or_recover() + .as_ref() + .and_then(|idx| turbovec_search_hits(idx, query, k)), + }; + #[cfg(not(feature = "turboquant-index"))] + let turbo_hits: Option> = None; + let turbo_elapsed = turbo_start.elapsed().as_micros(); + out.push(LabelSearchBenchmark { + backend: "turboquant", + available: turbo_hits.is_some(), + elapsed_us: turbo_elapsed, + results: turbo_hits.map_or_else(Vec::new, |hits| hydrate_hits(hits, &labels_db, skill_dir)), + }); + + out } #[cfg(test)] @@ -794,6 +1489,40 @@ mod tests { assert!(state.eeg.lock().unwrap().is_some()); } + #[test] + fn memory_footprint_empty_dir_is_zero() { + let dir = tempdir().unwrap(); + let state = LabelIndexState::new(); + let mem = state.memory_footprint(dir.path()); + assert_eq!(mem.total_bytes, 0); + assert_eq!(mem.hnsw.total(), 0); + assert_eq!(mem.turbovec.total(), 0); + } + + #[test] + fn memory_footprint_reports_existing_file_sizes() { + let dir = tempdir().unwrap(); + // Write fake index files of known sizes (memory_footprint only stats — the + // contents don't have to be valid index payloads). + std::fs::write(dir.path().join(TEXT_INDEX_FILE), vec![0u8; 1024]).unwrap(); + std::fs::write(dir.path().join(CONTEXT_INDEX_FILE), vec![0u8; 2048]).unwrap(); + std::fs::write(dir.path().join(EEG_INDEX_FILE), vec![0u8; 4096]).unwrap(); + std::fs::write(dir.path().join(TEXT_TURBOVEC_INDEX_FILE), vec![0u8; 512]).unwrap(); + // Leave context_turbovec / eeg_turbovec absent — they should report 0. + + let state = LabelIndexState::new(); + let mem = state.memory_footprint(dir.path()); + assert_eq!(mem.hnsw.text_bytes, 1024); + assert_eq!(mem.hnsw.context_bytes, 2048); + assert_eq!(mem.hnsw.eeg_bytes, 4096); + assert_eq!(mem.hnsw.total(), 7168); + assert_eq!(mem.turbovec.text_bytes, 512); + assert_eq!(mem.turbovec.context_bytes, 0); + assert_eq!(mem.turbovec.eeg_bytes, 0); + assert_eq!(mem.turbovec.total(), 512); + assert_eq!(mem.total_bytes, 7680); + } + fn create_labels_db(dir: &std::path::Path) -> rusqlite::Connection { let db_path = dir.join(LABELS_FILE); let conn = rusqlite::Connection::open(&db_path).unwrap(); @@ -1191,4 +1920,52 @@ mod tests { let stats = rebuild(dir.path(), &state); assert_eq!(stats.text_nodes, 0); // no text_embedding column filled } + + #[cfg(feature = "turboquant-index")] + #[test] + fn turbovec_rebuild_reload_and_benchmark() { + let dir = tempdir().unwrap(); + let conn = create_labels_db(dir.path()); + let to_blob = |v: &[f32]| -> Vec { v.iter().flat_map(|f| f.to_le_bytes()).collect() }; + let emb_a = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let emb_b = [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let ctx_a = [0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let ctx_b = [0.0, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0]; + + conn.execute( + "INSERT INTO labels (id, eeg_start, eeg_end, wall_start, wall_end, text, context, created_at, text_embedding, context_embedding, embedding_model) + VALUES (1, 0, 0, 0, 0, 'alpha', 'ctx alpha', 1, ?1, ?2, 'model')", + rusqlite::params![to_blob(&emb_a), to_blob(&ctx_a)], + ) + .unwrap(); + conn.execute( + "INSERT INTO labels (id, eeg_start, eeg_end, wall_start, wall_end, text, context, created_at, text_embedding, context_embedding, embedding_model) + VALUES (2, 0, 0, 0, 0, 'beta', 'ctx beta', 2, ?1, ?2, 'model')", + rusqlite::params![to_blob(&emb_b), to_blob(&ctx_b)], + ) + .unwrap(); + + let state = LabelIndexState::new(); + state.set_preferred_backend(LabelIndexBackend::TurboVec); + let stats = rebuild(dir.path(), &state); + assert_eq!(stats.text_nodes, 2); + assert_eq!(state.turbovec_counts().text_nodes, 2); + assert_eq!(state.turbovec_counts().context_nodes, 2); + assert!(dir.path().join(TEXT_TURBOVEC_INDEX_FILE).exists()); + assert!(dir.path().join(CONTEXT_TURBOVEC_INDEX_FILE).exists()); + + let results = search_by_text_vec(&emb_a, 2, HNSW_EF, dir.path(), &state); + assert_eq!(results[0].label_id, 1); + + let bench = benchmark_text_vec(&emb_a, 2, HNSW_EF, dir.path(), &state); + assert_eq!(bench.len(), 2); + assert!(bench.iter().any(|b| b.backend == "hnsw" && b.available)); + assert!(bench.iter().any(|b| b.backend == "turboquant" && b.available)); + + let reloaded = LabelIndexState::new(); + reloaded.load(dir.path()); + reloaded.set_preferred_backend(LabelIndexBackend::TurboVec); + let reloaded_results = search_by_context_vec(&ctx_b, 2, HNSW_EF, dir.path(), &reloaded); + assert_eq!(reloaded_results[0].label_id, 2); + } } diff --git a/crates/skill-llm/Cargo.toml b/crates/skill-llm/Cargo.toml index 731d69ca..80421e9a 100644 --- a/crates/skill-llm/Cargo.toml +++ b/crates/skill-llm/Cargo.toml @@ -7,12 +7,27 @@ description = "LLM inference engine for NeuroSkill — extracted workspace crate [features] default = [] -llm = ["dep:llama-cpp-4", "axum/multipart", "llm-mtmd"] -llm-metal = ["llm", "llama-cpp-4?/metal"] -llm-cuda = ["llm", "llama-cpp-4?/cuda"] -llm-vulkan = ["llm", "llama-cpp-4?/vulkan"] -llm-mtmd = ["llm", "llama-cpp-4?/mtmd"] -llm-native = ["llm", "llama-cpp-4?/native"] + +# ── Backend feature flags ───────────────────────────────────────────────────── +# `llm` is a marker auto-enabled by any backend — it gates the shared +# engine scaffolding (state, protocol, handlers, tool orchestration). +# +# Per-platform RLX backend variants (mirror rlx's own naming): +# macOS: llm-rlx-metal | llm-rlx-mlx (also brings BLAS Accelerate) +# Linux: llm-rlx-cuda | llm-rlx-rocm | llm-rlx-wgpu | llm-rlx-cpu +# Windows: llm-rlx-cuda | llm-rlx-wgpu | llm-rlx-cpu +# `llm-rlx-cpu` is the portable fallback (no GPU dependency; relies on +# rlx-cpu's scalar gemm or system BLAS if linked separately). +llm = [] +# Base rlx feature pulls cpu dispatch so the runtime always has at least +# one usable target. GPU-flavor features add their backend on top. +llm-rlx = ["llm", "dep:rlx", "dep:rlx-models", "dep:rlx-minicpm5", "dep:rlx-minimax", "dep:rlx-nemotron", "dep:rlx-gemma", "dep:image", "rlx?/cpu"] +llm-rlx-cpu = ["llm-rlx"] +llm-rlx-metal = ["llm-rlx", "rlx?/metal", "rlx?/blas-accelerate", "rlx-models?/metal", "rlx-minicpm5?/metal", "rlx-minimax?/metal", "rlx-nemotron?/metal", "rlx-gemma?/metal"] +llm-rlx-mlx = ["llm-rlx", "rlx?/mlx", "rlx?/blas-accelerate", "rlx-models?/mlx", "rlx-minicpm5?/mlx", "rlx-minimax?/mlx", "rlx-nemotron?/mlx", "rlx-gemma?/mlx"] +llm-rlx-cuda = ["llm-rlx", "rlx?/cuda", "rlx-models?/cuda", "rlx-minicpm5?/cuda", "rlx-minimax?/cuda", "rlx-nemotron?/cuda", "rlx-gemma?/cuda"] +llm-rlx-rocm = ["llm-rlx", "rlx?/rocm", "rlx-models?/rocm", "rlx-minicpm5?/rocm", "rlx-minimax?/rocm", "rlx-nemotron?/rocm", "rlx-gemma?/rocm"] +llm-rlx-wgpu = ["llm-rlx", "rlx?/gpu", "rlx-models?/gpu", "rlx-minicpm5?/gpu", "rlx-minimax?/gpu", "rlx-nemotron?/gpu", "rlx-gemma?/gpu"] [dependencies] anyhow = { workspace = true } @@ -33,17 +48,16 @@ either = "1" hf-hub = "0.5" skill-tools = { path = "../skill-tools", default-features = false } skill-skills = { path = "../skill-skills" } +rlx = { workspace = true, optional = true, features = ["cpu", "gguf"] } +rlx-models = { workspace = true, optional = true } +rlx-minicpm5 = { workspace = true, optional = true, features = ["tokenizer"] } +rlx-minimax = { workspace = true, optional = true } +rlx-nemotron = { workspace = true, optional = true } +rlx-gemma = { workspace = true, optional = true, features = ["tokenizer"] } +image = { version = "0.25", default-features = false, features = ["png", "webp", "jpeg"], optional = true } [build-dependencies] -llama-cpp-4 = { version = "0.2.50", optional = true, default-features = false, features = ["ggml", "native"] } - -[target.'cfg(target_os = "macos")'.dependencies] -llama-cpp-4 = { version = "0.2.50", optional = true, default-features = false, features = ["ggml", "metal", "native", "mtmd"] } - -[target.'cfg(not(target_os = "macos"))'.dependencies] -llama-cpp-4 = { version = "0.2.50", optional = true, default-features = false, features = ["ggml", "vulkan", "native", "mtmd"] } - [dev-dependencies] tempfile = "3" diff --git a/crates/skill-llm/src/catalog/download.rs b/crates/skill-llm/src/catalog/download.rs index a131e4df..f62abbf2 100644 --- a/crates/skill-llm/src/catalog/download.rs +++ b/crates/skill-llm/src/catalog/download.rs @@ -327,7 +327,7 @@ pub fn download_model(entry: &LlmModelEntry, progress: &Arc, #[serde(default)] pub is_mmproj: bool, + /// Whether models in this family were compiled with multi-token prediction + /// (MTP) support. MTP is activated via `LlamaContextType::Mtp` when the + /// engine chooses the speculative decoding path. + #[serde(default)] + pub mtp: bool, #[serde(default)] pub params_b: f64, #[serde(default)] @@ -81,6 +86,8 @@ pub struct LlmModelSlim { /// References a key in the `families` map. pub family: String, pub filename: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_filename: Option, pub quant: String, pub size_gb: f32, pub description: String, @@ -161,6 +168,7 @@ impl LlmCatalogNormalized { entries.push(LlmModelEntry { repo: m.repo.unwrap_or_else(|| fam.repo.clone()), filename: m.filename, + remote_filename: m.remote_filename, quant: m.quant, size_gb: m.size_gb, description: m.description, @@ -169,6 +177,7 @@ impl LlmCatalogNormalized { family_desc: fam.description.clone(), tags: fam.tags.clone(), is_mmproj: fam.is_mmproj, + mtp: fam.mtp, recommended: m.recommended, advanced: m.advanced, params_b: fam.params_b, @@ -207,6 +216,7 @@ impl LlmCatalog { repo: e.repo.clone(), tags: e.tags.clone(), is_mmproj: e.is_mmproj, + mtp: e.mtp, params_b: e.params_b, max_context_length: e.max_context_length, }); @@ -217,6 +227,7 @@ impl LlmCatalog { models.push(LlmModelSlim { family: e.family_id.clone(), filename: e.filename.clone(), + remote_filename: e.remote_filename.clone(), quant: e.quant.clone(), size_gb: e.size_gb, description: e.description.clone(), @@ -279,6 +290,10 @@ pub struct LlmModelEntry { /// Primary filename — for single-file models this is the only GGUF file. /// For split models this is the **first shard** (passed to llama.cpp). pub filename: String, + /// Optional upstream filename when the catalog needs a unique local/display + /// name but the remote HuggingFace file has a colliding name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_filename: Option, pub quant: String, /// Total size across all shard files (GB). pub size_gb: f32, @@ -289,6 +304,11 @@ pub struct LlmModelEntry { /// e.g. `["chat","reasoning","small"]` pub tags: Vec, pub is_mmproj: bool, + /// Whether this model was compiled with multi-token prediction (MTP). + /// Inherited from the family and used by the engine to gate speculative + /// decoding. + #[serde(default)] + pub mtp: bool, pub recommended: bool, /// Hidden in simple view; shown under "Show all quants". pub advanced: bool, @@ -349,7 +369,7 @@ impl LlmModelEntry { /// Iterator over all filenames that need to be downloaded / present. /// For single-file models this yields just `filename`. pub fn all_filenames(&self) -> impl Iterator { - let single = std::iter::once(self.filename.as_str()); + let single = std::iter::once(self.remote_filename()); let shards = self.shard_files.iter().map(String::as_str); // When shard_files is non-empty use it; otherwise fall back to filename. if self.shard_files.is_empty() { @@ -359,6 +379,11 @@ impl LlmModelEntry { } } + /// Remote HuggingFace filename/path for the primary model file. + pub fn remote_filename(&self) -> &str { + self.remote_filename.as_deref().unwrap_or(&self.filename) + } + /// Resolve local path of the **first shard** from the HF Hub cache — /// filesystem only, no network. /// @@ -368,7 +393,7 @@ impl LlmModelEntry { let cache = Cache::from_env(); let repo = cache.repo(Repo::model(self.repo.clone())); - let first = repo.get(&self.filename)?; + let first = repo.get(self.remote_filename())?; // For split models, verify every shard is present. if self.is_split() { @@ -455,6 +480,7 @@ mod tests { LlmModelEntry { repo: "test/repo".into(), filename: filename.into(), + remote_filename: None, quant: "Q4_K_M".into(), size_gb: 2.0, description: String::new(), @@ -465,6 +491,7 @@ mod tests { params_b: 4.0, max_context_length: 4096, is_mmproj: false, + mtp: false, recommended: false, advanced: false, shard_files: shards.iter().map(|s| s.to_string()).collect(), @@ -482,6 +509,7 @@ mod tests { LlmModelEntry { repo: "acme/Model-GGUF".into(), filename: "Model-Q4_K_M.gguf".into(), + remote_filename: None, quant: "Q4_K_M".into(), size_gb: 4.5, description: "Recommended".into(), @@ -490,6 +518,7 @@ mod tests { family_desc: "A great model.".into(), tags: vec!["chat".into(), "reasoning".into()], is_mmproj: false, + mtp: false, recommended: true, advanced: false, params_b: 7.0, @@ -504,6 +533,7 @@ mod tests { LlmModelEntry { repo: "acme/Model-GGUF".into(), filename: "Model-Q2_K.gguf".into(), + remote_filename: None, quant: "Q2_K".into(), size_gb: 2.8, description: "Smallest".into(), @@ -512,6 +542,7 @@ mod tests { family_desc: "A great model.".into(), tags: vec!["chat".into(), "reasoning".into()], is_mmproj: false, + mtp: false, recommended: false, advanced: true, params_b: 7.0, @@ -526,6 +557,7 @@ mod tests { LlmModelEntry { repo: "other/Vision-GGUF".into(), filename: "Vision-mmproj-F16.gguf".into(), + remote_filename: None, quant: "F16".into(), size_gb: 1.2, description: "Vision projector".into(), @@ -534,6 +566,7 @@ mod tests { family_desc: "Vision model.".into(), tags: vec!["vision".into()], is_mmproj: true, + mtp: false, recommended: true, advanced: false, params_b: 0.6, @@ -788,6 +821,7 @@ mod tests { cat.entries.push(LlmModelEntry { repo: "user/Custom-GGUF".into(), filename: "Custom-Q4_K_M.gguf".into(), + remote_filename: None, quant: "Q4_K_M".into(), size_gb: 5.0, description: "Custom model".into(), @@ -796,6 +830,7 @@ mod tests { family_desc: "User's custom model.".into(), tags: vec!["chat".into()], is_mmproj: false, + mtp: false, recommended: false, advanced: false, params_b: 13.0, @@ -835,6 +870,7 @@ mod tests { models: vec![LlmModelSlim { family: "nonexistent".into(), filename: "ghost.gguf".into(), + remote_filename: None, quant: "Q4_K_M".into(), size_gb: 1.0, description: "orphan".into(), @@ -860,6 +896,7 @@ mod tests { cat.entries.push(LlmModelEntry { repo: "acme/BigModel-GGUF".into(), filename: "BigModel-Q4_K_M-00001-of-00003.gguf".into(), + remote_filename: None, quant: "Q4_K_M".into(), size_gb: 30.0, description: "Sharded model".into(), @@ -868,6 +905,7 @@ mod tests { family_desc: "A big model.".into(), tags: vec!["chat".into(), "large".into()], is_mmproj: false, + mtp: false, recommended: true, advanced: false, params_b: 70.0, @@ -940,6 +978,7 @@ mod tests { entries: vec![LlmModelEntry { repo: "r/m".into(), filename: format!("m-{:?}.gguf", state), + remote_filename: None, quant: "Q4_0".into(), size_gb: 1.0, description: String::new(), @@ -948,6 +987,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: false, + mtp: false, recommended: false, advanced: false, params_b: 1.0, @@ -995,6 +1035,7 @@ mod tests { LlmModelEntry { repo: "r/m".into(), filename: "a.gguf".into(), + remote_filename: None, quant: "Q4_K_M".into(), size_gb: 4.0, description: "A".into(), @@ -1003,6 +1044,7 @@ mod tests { family_desc: "First description.".into(), tags: vec!["chat".into()], is_mmproj: false, + mtp: false, recommended: true, advanced: false, params_b: 7.0, @@ -1017,6 +1059,7 @@ mod tests { LlmModelEntry { repo: "r/m".into(), filename: "b.gguf".into(), + remote_filename: None, quant: "Q2_K".into(), size_gb: 2.0, description: "B".into(), @@ -1025,6 +1068,7 @@ mod tests { family_desc: "Second description.".into(), tags: vec!["reasoning".into()], is_mmproj: false, + mtp: false, recommended: false, advanced: true, params_b: 7.0, @@ -1183,6 +1227,7 @@ mod tests { LlmModelEntry { repo: "org/VL-GGUF".into(), filename: "VL-Q4_K_M.gguf".into(), + remote_filename: None, quant: "Q4_K_M".into(), size_gb: 10.0, description: "Main".into(), @@ -1191,6 +1236,7 @@ mod tests { family_desc: "Vision-language.".into(), tags: vec!["vision".into()], is_mmproj: false, + mtp: false, recommended: true, advanced: false, params_b: 30.0, @@ -1205,6 +1251,7 @@ mod tests { LlmModelEntry { repo: "org/VL-GGUF".into(), filename: "VL-mmproj-F16.gguf".into(), + remote_filename: None, quant: "F16".into(), size_gb: 1.5, description: "Vision projector".into(), @@ -1213,6 +1260,7 @@ mod tests { family_desc: "Multimodal projector.".into(), tags: vec!["vision".into()], is_mmproj: true, + mtp: false, recommended: true, advanced: false, params_b: 0.6, @@ -1247,6 +1295,7 @@ mod tests { cat.entries.push(LlmModelEntry { repo: "r/m".into(), filename: format!("model-{i}.gguf"), + remote_filename: None, quant: "Q4_0".into(), size_gb: i as f32, description: format!("entry {i}"), @@ -1255,6 +1304,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: false, + mtp: false, recommended: false, advanced: false, params_b: 1.0, diff --git a/crates/skill-llm/src/config.rs b/crates/skill-llm/src/config.rs index ab4f79d0..2afdc026 100644 --- a/crates/skill-llm/src/config.rs +++ b/crates/skill-llm/src/config.rs @@ -11,6 +11,15 @@ pub use skill_tools::types::{LlmToolConfig, ToolExecutionMode}; // ── LLM server configuration ───────────────────────────────────────────────── +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LlmInferenceRuntime { + /// Legacy value kept for serde compat with existing config files; treated as Rlx. + LlamaCpp, + #[default] + Rlx, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct LlmConfig { @@ -23,6 +32,11 @@ pub struct LlmConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub model_path: Option, + /// Inference runtime. Always RLX in practice; `llama_cpp` is accepted in + /// existing config files but treated identically. + #[serde(default)] + pub runtime: LlmInferenceRuntime, + /// Number of transformer layers to offload to the GPU. /// `0` = CPU-only inference. `-1` (stored as `u32::MAX`) = offload all. #[serde(default)] @@ -68,7 +82,7 @@ pub struct LlmConfig { #[serde(default = "default_autoload_mmproj")] pub autoload_mmproj: bool, - /// Enable verbose llama.cpp / clip_model_loader logging to stderr. + /// Enable verbose inference / clip_model_loader logging to stderr. #[serde(default)] pub verbose: bool, @@ -128,7 +142,7 @@ pub struct LlmConfig { #[serde(default, skip_serializing)] pub max_context_length: u32, - // ── TurboQuant KV-cache settings (llama-cpp-4 ≥ 0.2.20) ────────────────── + // ── TurboQuant KV-cache settings ───────────────────────────────────────── /// Storage type for the **K** (key) KV-cache tensors. /// /// Options: `"f16"` (default, highest quality), `"q8_0"` (saves ~47% VRAM, @@ -145,15 +159,49 @@ pub struct LlmConfig { #[serde(default = "default_cache_type_v")] pub cache_type_v: String, - /// Disable the TurboQuant attention rotation (llama.cpp PR #21038). + /// Disable the TurboQuant attention rotation. /// - /// When `false` (the default), llama.cpp applies a Hadamard rotation to - /// Q/K/V tensors before writing them to the KV cache. This significantly - /// improves the quality of quantized KV caches at near-zero overhead. - /// Set to `true` only if you experience compatibility issues with a - /// particular model. + /// When `false` (the default), a Hadamard rotation is applied to Q/K/V + /// tensors before writing to the KV cache, significantly improving the + /// quality of quantized KV caches at near-zero overhead. Set to `true` + /// only if you experience compatibility issues with a particular model. #[serde(default)] pub attn_rot_disabled: bool, + + // ── Multi-Token Prediction ─────────────────────────────────────────────── + /// Number of MTP draft tokens generated per decode step. + /// + /// `0` = MTP disabled (default). Typical values: `1` for Q4 models, + /// `3` for Q8 models (per the v0.2.53 bench: `=3` regressed, `=1` gained + /// +6.2% on Qwen3.6-27B-Q4_K_M-mtp). Requires an MTP-capable model + /// (e.g. the `froggeric/Qwen3.6-27B-MTP-GGUF` family). + #[serde(default)] + pub mtp_draft_count: u32, + + /// Number of recurrent-state snapshots per sequence on the draft context. + /// Must be `>= mtp_draft_count` so partial KV rollback after rejected + /// drafts succeeds on hybrid/recurrent models (e.g. Qwen3.6 M-RoPE). + /// `0` lets the smoke-validation step pick a sensible default (4). + #[serde(default)] + pub mtp_n_rs_seq: u32, + + /// Carries the catalog `mtp` flag for the active model into the actor. + /// Set by `init.rs` from `LlmModelEntry::mtp` — not user-configurable. + #[serde(default, skip_serializing)] + pub mtp_capable: bool, + + // ── RLX experimental runtime ───────────────────────────────────────────── + /// RLX device tag: `"cpu"`, `"metal"`, `"mlx"`, `"gpu"`, `"cuda"`, etc. + #[serde(default = "default_rlx_device")] + pub rlx_device: String, + + /// RLX Qwen3 prefill/decode bucket length. + #[serde(default = "default_rlx_max_seq")] + pub rlx_max_seq: usize, + + /// Optional RLX soft cap for F32 dequantized weight memory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rlx_max_memory_gb: Option, } fn default_llm_parallel() -> usize { @@ -183,12 +231,23 @@ fn default_cache_type_k() -> String { fn default_cache_type_v() -> String { "f16".into() } +fn default_rlx_device() -> String { + if cfg!(target_os = "macos") { + "metal".into() + } else { + "cpu".into() + } +} +fn default_rlx_max_seq() -> usize { + 128 +} impl Default for LlmConfig { fn default() -> Self { Self { enabled: false, model_path: None, + runtime: LlmInferenceRuntime::Rlx, n_gpu_layers: u32::MAX, ctx_size: None, parallel: default_llm_parallel(), @@ -212,6 +271,12 @@ impl Default for LlmConfig { cache_type_k: default_cache_type_k(), cache_type_v: default_cache_type_v(), attn_rot_disabled: false, + mtp_draft_count: 0, + mtp_n_rs_seq: 0, + mtp_capable: false, + rlx_device: default_rlx_device(), + rlx_max_seq: default_rlx_max_seq(), + rlx_max_memory_gb: None, } } } diff --git a/crates/skill-llm/src/engine/actor.rs b/crates/skill-llm/src/engine/actor.rs deleted file mode 100644 index 102e6e25..00000000 --- a/crates/skill-llm/src/engine/actor.rs +++ /dev/null @@ -1,901 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -// Copyright (C) 2026 NeuroSkill.com -//! Inference actor — the OS thread that owns the model and context. - -use anyhow::Context; -use std::{ - num::NonZeroU32, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, - }, -}; - -use serde_json::{json, Value}; - -use llama_cpp_4::{ - context::params::{LlamaContextParams, LlamaPoolingType}, - llama_backend::LlamaBackend, - llama_batch::LlamaBatch, - model::{params::LlamaModelParams, AddBos, LlamaModel}, - quantize::GgmlType, -}; - -use super::generation::{run_generation, GpuMemoryGuard}; -use super::logging::{LlmLogBuffer, LlmLogFile}; -use super::protocol::{InferRequest, InferToken}; -use crate::config::LlmConfig; -use crate::event::LlmEventEmitter; - -/// Map a human-readable KV-cache type tag from [`LlmConfig`] to a [`GgmlType`]. -/// -/// Accepted tags (case-insensitive): `"f16"`, `"q8_0"`, `"q5_0"`, `"q4_0"`. -/// Unknown tags fall back to `GgmlType::F16`. -fn cache_ggml_type(tag: &str) -> GgmlType { - match tag.to_ascii_lowercase().as_str() { - "q4_0" => GgmlType::Q4_0, - "q5_0" => GgmlType::Q5_0, - "q8_0" => GgmlType::Q8_0, - _ => GgmlType::F16, - } -} - -#[cfg(feature = "llm-mtmd")] -use super::generation::run_generation_multimodal; - -#[allow(clippy::too_many_arguments)] -pub(super) fn run_actor( - mut rx: tokio::sync::mpsc::UnboundedReceiver, - config: LlmConfig, - model_path: std::path::PathBuf, - mmproj_path: Option, - app: Arc, - log_buf: LlmLogBuffer, - log_path: Option, - ready_flag: Arc, - n_ctx_flag: Arc, - vision_flag: Arc, -) { - // ── per-session log file ────────────────────────────────────────────────── - let log_file_handle: Option = log_path.as_ref().and_then(|p| { - std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(p) - .ok() - .map(|f| Arc::new(Mutex::new(std::io::BufWriter::new(f)))) - }); - let log_file = log_file_handle.as_ref(); - - // ── init backend ────────────────────────────────────────────────────────── - // ── Windows-specific Vulkan SDK setup ───────────────────────────────────── - #[cfg(target_os = "windows")] - { - if let Ok(vulkan_sdk_path) = std::env::var("VULKAN_SDK") { - let vulkan_bin = std::path::Path::new(&vulkan_sdk_path).join("Bin"); - let vulkan_bin_str = vulkan_bin.to_string_lossy().to_string(); - - if let Ok(current_path) = std::env::var("PATH") { - std::env::set_var("PATH", format!("{};{}", vulkan_bin_str, current_path)); - llm_info!( - &app, - &log_buf, - log_file, - "Vulkan SDK Bin directory injected into PATH: {}", - vulkan_bin_str - ); - } else { - std::env::set_var("PATH", &vulkan_bin_str); - } - } - } - - #[cfg(not(target_os = "windows"))] - { - // Non-Windows: no special Vulkan path handling needed - } - - // ── LlamaBackend init ───────────────────────────────────────────────────── - // - // `LlamaBackend` is a ZST (zero-sized type) — a proof-of-init token. The - // actual backend state lives in C globals initialised by llama_backend_init. - // - // When WE initialise (Ok branch): store in a plain `Option`. - // Dropping it at actor shutdown calls llama_backend_free() and resets the - // LLAMA_BACKEND_INITIALIZED atomic, so neutts TTS can re-claim the backend - // after the LLM server stops. - // - // When someone ELSE (neutts) already initialised (Err branch): create a - // ManuallyDrop placeholder so we never call llama_backend_free() on a - // backend we don't own. - let mut _owned_backend: Option; - // SAFETY: LlamaBackend is a ZST; zeroed() is a valid instance used only as - // a borrowed token when we don't own the backend. ManuallyDrop prevents - // the Drop impl from calling llama_backend_free() in that case. - let mut _unowned_backend: std::mem::ManuallyDrop = - std::mem::ManuallyDrop::new(unsafe { std::mem::zeroed::() }); - let backend: &mut LlamaBackend; - - match LlamaBackend::init() { - Ok(mut b) => { - llm_info!(&app, &log_buf, log_file, "llama backend initialised"); - if !config.verbose { - b.void_logs(); - } - _owned_backend = Some(b); - #[allow(clippy::unwrap_used)] // always Some — set one line above - { - backend = _owned_backend.as_mut().unwrap(); - } - } - Err(_) => { - llm_info!( - &app, - &log_buf, - log_file, - "llama backend already initialised (shared with neutts TTS)" - ); - _owned_backend = None; - if !config.verbose { - _unowned_backend.void_logs(); - } - backend = &mut *_unowned_backend; - } - } - - // ── load model ── - let model_file_name = model_path.file_name().and_then(|n| n.to_str()).unwrap_or("model"); - app.emit_event( - "llm:status", - json!({"status":"loading","detail":"loading_model","model":model_file_name}), - ); - llm_info!(&app, &log_buf, log_file, "loading model: {}", model_path.display()); - let model_params = LlamaModelParams::default().with_n_gpu_layers(config.n_gpu_layers); - - let model = match LlamaModel::load_from_file(backend, &model_path, &model_params) { - Ok(m) => { - llm_info!(&app, &log_buf, log_file, "model loaded ✓"); - m - } - Err(e) => { - llm_error!(&app, &log_buf, log_file, "failed to load model: {e}"); - app.emit_event( - "llm:status", - json!({"status":"stopped","error":format!("failed to load model: {e}")}), - ); - return; - } - }; - - // ── create generation context ── - // ctx_size is normally resolved by init.rs (auto-recommended or user-set). - // Fallback to 16384 when still None (model not in catalog / tests) — - // the system prompt with tool definitions + EEG context injection can - // easily exceed 8K tokens. - let ctx_size = config.ctx_size.or(Some(16384)).and_then(NonZeroU32::new); - app.emit_event( - "llm:status", - json!({"status":"loading","detail":"creating_context","model":model_file_name}), - ); - let kv_type_k = cache_ggml_type(&config.cache_type_k); - let kv_type_v = cache_ggml_type(&config.cache_type_v); - llm_info!( - &app, - &log_buf, - log_file, - "creating context (n_ctx={}, n_gpu_layers={}, flash_attn={}, offload_kqv={}, \ - cache_k={:?}, cache_v={:?}, attn_rot_disabled={})", - ctx_size.map_or(0, NonZeroU32::get), - config.n_gpu_layers, - config.flash_attention, - config.offload_kqv, - kv_type_k, - kv_type_v, - config.attn_rot_disabled - ); - // n_batch: max tokens per decode call (prompt prefill). - // Larger = faster prefill at the cost of more peak memory. - let n_batch = config - .n_batch - .unwrap_or_else(|| ctx_size.map_or(4096, |n| n.get().min(4096))); - // n_ubatch: micro-batch for BLAS/GPU kernel dispatch. - let n_ubatch = config.n_ubatch.unwrap_or_else(|| n_batch.min(2048)); - - let ctx_params = LlamaContextParams::default() - .with_n_ctx(ctx_size) - .with_n_batch(n_batch) - .with_n_ubatch(n_ubatch) - .with_n_threads(-1) - .with_n_threads_batch(-1) - .with_flash_attention(config.flash_attention) - .with_offload_kqv(config.offload_kqv) - .with_cache_type_k(kv_type_k) - .with_cache_type_v(kv_type_v) - .with_attn_rot_disabled(config.attn_rot_disabled); - - let mut ctx = match model.new_context(backend, ctx_params) { - Ok(c) => c, - Err(e) => { - llm_error!(&app, &log_buf, log_file, "failed to create context: {e}"); - app.emit_event( - "llm:status", - json!({"status":"stopped","error":format!("failed to create context: {e}")}), - ); - return; - } - }; - - n_ctx_flag.store(ctx.n_ctx() as usize, Ordering::Relaxed); - llm_info!( - &app, - &log_buf, - log_file, - "context ready — n_ctx={} — running warmup pass…", - ctx.n_ctx() - ); - - // ── Windows Vulkan diagnostic check ──────────────────────────────────────── - #[cfg(target_os = "windows")] - { - let n_layers = config.n_gpu_layers; - if n_layers > 0 { - llm_info!(&app, &log_buf, log_file, "GPU offload requested: {} layer(s)", n_layers); - llm_warn!( - &app, - &log_buf, - log_file, - "on Windows, ensure Vulkan SDK is installed and VULKAN_SDK env var is set" - ); - } - } - - #[cfg(not(target_os = "windows"))] - { - // Non-Windows systems — Metal (macOS) and CUDA handle device detection differently - } - - app.emit_event( - "llm:status", - json!({"status":"loading","detail":"warming_up","model":model_file_name}), - ); - - // ── Multimodal projector (llm-mtmd feature) ─────────────────────────────── - #[cfg(feature = "llm-mtmd")] - extern "C" { - fn mtmd_log_set( - log_callback: Option< - unsafe extern "C" fn( - level: u32, - text: *const std::os::raw::c_char, - user_data: *mut std::os::raw::c_void, - ), - >, - user_data: *mut std::os::raw::c_void, - ); - } - - #[cfg(feature = "llm-mtmd")] - let mtmd_ctx: Option = { - if mmproj_path.is_none() { - llm_info!( - &app, - &log_buf, - log_file, - "vision disabled — no mmproj file configured; \ - download a vision projector in Settings → LLM to enable image input" - ); - } - mmproj_path.as_ref().and_then(|p| { - use llama_cpp_4::mtmd::{MtmdContext, MtmdContextParams}; - app.emit_event( - "llm:status", - json!({"status":"loading","detail":"loading_vision","model":model_file_name}), - ); - - if !p.exists() { - llm_error!( - &app, - &log_buf, - log_file, - "mmproj file missing: {} — vision disabled", - p.display() - ); - return None; - } - - if !config.verbose { - // SAFETY: `noop` is a valid C-calling-convention function that - // ignores all arguments. `mtmd_log_set` stores the callback - // globally — `noop` has 'static lifetime (it's a function item). - unsafe extern "C" fn noop( - _level: u32, - _text: *const std::os::raw::c_char, - _ud: *mut std::os::raw::c_void, - ) { - } - // SAFETY: `noop` has a 'static lifetime (function item) and - // matches the expected C callback signature. null user-data is valid. - unsafe { mtmd_log_set(Some(noop), std::ptr::null_mut()) }; - } - - let file_size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0); - if file_size < 1024 { - llm_error!( - &app, - &log_buf, - log_file, - "mmproj file too small ({file_size} bytes): {} — \ - likely a failed download; re-download in Settings → LLM", - p.display() - ); - return None; - } - - let force_mmproj_gpu = std::env::var("SKILL_FORCE_MMPROJ_GPU") - .ok() - .as_deref() - .map(|v| matches!(v, "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false); - - let mmproj_use_gpu = !config.no_mmproj_gpu || force_mmproj_gpu; - - llm_info!( - &app, - &log_buf, - log_file, - "loading mmproj: {} ({:.1} MB, gpu={}, threads={})", - p.display(), - file_size as f64 / 1_048_576.0, - mmproj_use_gpu, - config.mmproj_n_threads - ); - - let try_load_mmproj = |use_gpu: bool| -> anyhow::Result { - let params = MtmdContextParams::default() - .use_gpu(use_gpu) - .n_threads(config.mmproj_n_threads) - .print_timings(config.verbose) - .warmup(false); - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - MtmdContext::init_from_file(p, &model, params) - })) { - Ok(Ok(mc)) => Ok(mc), - Ok(Err(e)) => Err(anyhow::anyhow!("{e}")), - Err(_panic) => Err(anyhow::anyhow!("panic in native code")), - } - }; - - let result = try_load_mmproj(mmproj_use_gpu); - - let result = match result { - Ok(mc) => Ok(mc), - Err(ref gpu_err) if mmproj_use_gpu && cfg!(target_os = "linux") && !force_mmproj_gpu => { - llm_warn!( - &app, - &log_buf, - log_file, - "mmproj GPU load failed ({gpu_err}); retrying on CPU…" - ); - try_load_mmproj(false) - } - Err(e) => Err(e), - }; - - match result { - Ok(mc) => { - llm_info!( - &app, - &log_buf, - log_file, - "mmproj loaded ✓ — vision={} audio={}", - mc.supports_vision(), - mc.supports_audio() - ); - vision_flag.store(true, Ordering::Relaxed); - Some(mc) - } - Err(e) => { - llm_error!( - &app, - &log_buf, - log_file, - "failed to load mmproj: {e} — file: {}", - p.display() - ); - llm_info!( - &app, - &log_buf, - log_file, - "vision disabled — to enable image input, \ - ensure the mmproj file matches your model or re-download it in Settings → LLM" - ); - None - } - } - }) - }; - #[cfg(not(feature = "llm-mtmd"))] - let _ = &mmproj_path; - - // Build GPU memory guard from config thresholds. - let gpu_guard = GpuMemoryGuard { - decode_threshold: config.gpu_memory_threshold, - gen_threshold: config.gpu_memory_gen_threshold, - }; - - // ── Warmup / prewarm ────────────────────────────────────────────────────── - let warmup_ok = (|| -> bool { - // Pre-check GPU memory to avoid Metal abort() during warmup. - let (mem_ok, free_gb) = super::generation::gpu_memory_check(gpu_guard.decode_threshold); - if !mem_ok { - llm_warn!( - &app, - &log_buf, - log_file, - "skipping warmup — insufficient GPU memory ({:.2} GB free < {:.2} GB threshold)", - free_gb.unwrap_or(0.0), - gpu_guard.decode_threshold - ); - return false; - } - - let bos = model.token_bos(); - let warmup_tokens = if let Ok(toks) = model.str_to_token(" ", AddBos::Always) { - toks - } else { - vec![bos] - }; - let n = warmup_tokens.len().min(4); - let mut batch = LlamaBatch::new(n, 1); - for (i, &tok) in warmup_tokens[..n].iter().enumerate() { - let last = i == n - 1; - if batch.add(tok, i as i32, &[0], last).is_err() { - return false; - } - } - let ok = ctx.decode(&mut batch).is_ok(); - ctx.clear_kv_cache(); - if ok { - return true; - } - - // Retry once after a brief delay (transient Metal failures). - llm_warn!(&app, &log_buf, log_file, "warmup decode failed — retrying after 200ms"); - std::thread::sleep(std::time::Duration::from_millis(200)); - let mut batch2 = LlamaBatch::new(n, 1); - for (i, &tok) in warmup_tokens[..n].iter().enumerate() { - let last = i == n - 1; - if batch2.add(tok, i as i32, &[0], last).is_err() { - return false; - } - } - let ok2 = ctx.decode(&mut batch2).is_ok(); - ctx.clear_kv_cache(); - ok2 - })(); - - if warmup_ok { - llm_info!( - &app, - &log_buf, - log_file, - "warmup complete — GPU kernels compiled, weights in VRAM" - ); - } else { - llm_warn!( - &app, - &log_buf, - log_file, - "warmup decode failed — first request may be slow" - ); - } - - // Signal that the model is fully loaded and warmed up. - ready_flag.store(true, Ordering::Relaxed); - let model_file = model_path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); - let vision_loaded = vision_flag.load(Ordering::Relaxed); - llm_info!( - &app, - &log_buf, - log_file, - "server ready — model={} supports_vision={}", - model_file, - vision_loaded - ); - app.emit_event( - "llm:status", - json!({"status":"running","model":model_file,"supports_vision":vision_loaded,"supports_tools":true}), - ); - - // ── event loop ── - while let Some(req) = rx.blocking_recv() { - match req { - InferRequest::Health { result_tx } => { - result_tx.send(true).ok(); - } - - InferRequest::Generate { - messages, - images, - params, - token_tx, - } => { - llm_info!( - &app, - &log_buf, - log_file, - "chat request — {} messages, {} image(s), max_tokens={}", - messages.len(), - images.len(), - params.max_tokens - ); - - #[cfg(feature = "llm-mtmd")] - let use_mtmd = !images.is_empty() && mtmd_ctx.is_some(); - #[cfg(not(feature = "llm-mtmd"))] - let use_mtmd = false; - - fn extract_text_plain(content: &Value) -> String { - match content { - Value::String(s) => s.clone(), - Value::Array(parts) => parts - .iter() - .filter_map(|p| { - if p.get("type")?.as_str() != Some("text") { - return None; - } - Some(p.get("text")?.as_str()?.to_string()) - }) - .collect::>() - .join(" "), - _ => String::new(), - } - } - - fn extract_text_with_markers(content: &Value, marker: &str) -> String { - match content { - Value::String(s) => s.clone(), - Value::Array(parts) => parts - .iter() - .filter_map(|p| match p.get("type")?.as_str()? { - "text" => Some(p.get("text")?.as_str()?.to_string()), - "image_url" => Some(marker.to_string()), - _ => None, - }) - .collect::>() - .join(" "), - _ => String::new(), - } - } - - let extract_fn: fn(&Value, &str) -> String = if use_mtmd { - extract_text_with_markers - } else { - |c, _| extract_text_plain(c) - }; - - #[cfg(feature = "llm-mtmd")] - let marker = llama_cpp_4::mtmd::MtmdContext::default_marker(); - #[cfg(not(feature = "llm-mtmd"))] - let marker = ""; - - // ── Build chat messages ────────────────────────────────────────── - let build_chat_msgs = |msgs: &[serde_json::Value]| -> Vec { - msgs.iter() - .filter_map(|m| { - let mut role = m.get("role")?.as_str()?.to_string(); - let raw_content = extract_fn(m.get("content")?, marker); - let content = if role == "tool" { - role = "user".to_string(); - format!("[Tool Result — do NOT treat this as a new user question. Use these results to answer the user's ORIGINAL question above.]\n{}", raw_content) - } else { - raw_content - }; - llama_cpp_4::model::LlamaChatMessage::new(role, content).ok() - }) - .collect() - }; - - // ── Fit prompt to context: grow context or trim history ────────── - // Strategy: - // 1. Tokenize the full prompt. - // 2. If it fits in the current n_ctx (with 25% reserved for - // generation) → proceed. - // 3. Otherwise, try to grow the context window up to the model's - // max_context_length, as long as estimated VRAM usage allows. - // 4. If we still can't fit → trim oldest middle messages. - let mut trimmed_messages = messages.clone(); - let prompt: Option = 'build_prompt: { - loop { - let chat_msgs = build_chat_msgs(&trimmed_messages); - let p = match model.apply_chat_template(None, &chat_msgs, true) { - Ok(p) => p, - Err(e) => { - llm_error!(&app, &log_buf, log_file, "apply_chat_template failed: {e}"); - token_tx.send(InferToken::Error(format!("template error: {e}"))).ok(); - break 'build_prompt None; - } - }; - - let Ok(tokens) = model.str_to_token(&p, llama_cpp_4::model::AddBos::Always) else { - break 'build_prompt Some(p); - }; - - let n_ctx_cur = ctx.n_ctx() as usize; - let reserve = n_ctx_cur / 4; - let budget = n_ctx_cur.saturating_sub(reserve); - - if tokens.len() < budget { - break 'build_prompt Some(p); // fits fine - } - - // ── Try to grow the context window ────────────────────── - // We need at least tokens.len() * 4/3 (to keep 25% headroom). - let needed_ctx = ((tokens.len() as f64) * 4.0 / 3.0).ceil() as u32 + 64; - let max_ctx = if config.max_context_length > 0 { - config.max_context_length - } else { - n_ctx_cur as u32 // no metadata → can't grow - }; - - if needed_ctx > n_ctx_cur as u32 && needed_ctx <= max_ctx { - // Check if memory allows the larger context. - let can_afford = if config.params_b > 0.0 { - let gpu = skill_data::gpu_stats::read(); - let available_gb: f64 = gpu - .as_ref() - .and_then(|g| { - if g.is_unified_memory { - g.free_memory_bytes.map(|b| b as f64 / (1024.0 * 1024.0 * 1024.0)) - } else { - g.total_memory_bytes.map(|b| b as f64 / (1024.0 * 1024.0 * 1024.0)) - } - }) - .unwrap_or(0.0); - let mem_budget = available_gb * 0.70; - let estimated = - crate::catalog::estimate_memory_gb(config.params_b, &config.quant, needed_ctx); - estimated <= mem_budget - } else { - false // no model metadata → don't risk OOM - }; - - if can_afford { - // Round up to next power-of-two-ish standard size for cleanliness. - let new_ctx = [4096u32, 8192, 16384, 32768, 65536, 131072] - .iter() - .copied() - .find(|&c| c >= needed_ctx && c <= max_ctx) - .unwrap_or(needed_ctx.min(max_ctx)); - - llm_info!( - &app, - &log_buf, - log_file, - "prompt needs {} tokens, growing context {} -> {} (max={})", - tokens.len(), - n_ctx_cur, - new_ctx, - max_ctx - ); - - let resize_n_batch = config.n_batch.unwrap_or_else(|| new_ctx.min(4096)); - let resize_n_ubatch = config.n_ubatch.unwrap_or_else(|| resize_n_batch.min(2048)); - let new_ctx_params = LlamaContextParams::default() - .with_n_ctx(NonZeroU32::new(new_ctx)) - .with_n_batch(resize_n_batch) - .with_n_ubatch(resize_n_ubatch) - .with_n_threads(-1) - .with_n_threads_batch(-1) - .with_flash_attention(config.flash_attention) - .with_offload_kqv(config.offload_kqv) - .with_cache_type_k(kv_type_k) - .with_cache_type_v(kv_type_v) - .with_attn_rot_disabled(config.attn_rot_disabled); - - match model.new_context(backend, new_ctx_params) { - Ok(new_c) => { - ctx = new_c; - n_ctx_flag.store(ctx.n_ctx() as usize, Ordering::Relaxed); - llm_info!(&app, &log_buf, log_file, "context resized to n_ctx={}", ctx.n_ctx()); - // Re-check with new budget - let new_budget = - (ctx.n_ctx() as usize).saturating_sub(ctx.n_ctx() as usize / 4); - if tokens.len() < new_budget { - break 'build_prompt Some(p); - } - // Still doesn't fit — fall through to trimming - } - Err(e) => { - llm_warn!( - &app, - &log_buf, - log_file, - "failed to grow context to {}: {e} — will trim messages instead", - new_ctx - ); - } - } - } - } - - // ── Fall back: trim oldest middle messages ─────────────── - if trimmed_messages.len() <= 2 { - llm_warn!( - &app, - &log_buf, - log_file, - "prompt still too long after trimming all history ({} >= {})", - tokens.len(), - budget - ); - break 'build_prompt Some(p); // let generation.rs emit the error - } - llm_info!(&app, &log_buf, log_file, - "prompt too long ({} tokens >= {} budget, n_ctx={}), dropping message at index 1 ({} messages remaining)", - tokens.len(), budget, n_ctx_cur, trimmed_messages.len() - 1); - trimmed_messages.remove(1); - } - }; - let Some(prompt) = prompt else { continue }; - - #[cfg(feature = "llm-mtmd")] - if use_mtmd { - if let Some(ref mc) = mtmd_ctx { - run_generation_multimodal( - &model, &mut ctx, mc, &app, &log_buf, log_file, prompt, images, params, token_tx, gpu_guard, - ); - continue; - } - } - - run_generation( - &model, &mut ctx, &app, &log_buf, log_file, prompt, params, token_tx, gpu_guard, - ); - } - - InferRequest::Complete { - prompt, - params, - token_tx, - } => { - llm_info!( - &app, - &log_buf, - log_file, - "completion request — max_tokens={}", - params.max_tokens - ); - run_generation( - &model, &mut ctx, &app, &log_buf, log_file, prompt, params, token_tx, gpu_guard, - ); - } - - InferRequest::Embed { inputs, result_tx } => { - llm_info!( - &app, - &log_buf, - log_file, - "embeddings request — {} input(s)", - inputs.len() - ); - let emb_params = LlamaContextParams::default() - .with_n_ctx(NonZeroU32::new(512)) - .with_embeddings(true) - .with_pooling_type(LlamaPoolingType::Mean); - - let mut emb_ctx = match model.new_context(backend, emb_params) { - Ok(c) => c, - Err(e) => { - result_tx.send(Err(anyhow::anyhow!("{e}"))).ok(); - continue; - } - }; - - let embed_result: anyhow::Result>> = (|| { - let mut all = Vec::new(); - for text in &inputs { - emb_ctx.clear_kv_cache(); - - let tokens = model.str_to_token(text, AddBos::Always)?; - let n = tokens.len().min(emb_ctx.n_ctx() as usize - 1); - - let mut batch = LlamaBatch::new(n + 1, 1); - for (i, &tok) in tokens[..n].iter().enumerate() { - let last = i == n - 1; - batch.add(tok, i as i32, &[0], last).ok(); - } - - emb_ctx.decode(&mut batch).context("embed decode error")?; - - let vec = emb_ctx.embeddings_seq_ith(0)?; - all.push(vec.to_vec()); - } - Ok(all) - })(); - - if let Ok(ref vecs) = embed_result { - llm_info!(&app, &log_buf, log_file, "embeddings done — {} vector(s)", vecs.len()); - } - result_tx.send(embed_result).ok(); - } - - InferRequest::EmbedImage { bytes, result_tx } => { - #[cfg(feature = "llm-mtmd")] - { - if let Some(ref mtmd) = mtmd_ctx { - use llama_cpp_4::mtmd::{ - MtmdBitmap, MtmdContext, MtmdInputChunkType, MtmdInputChunks, MtmdInputText, - }; - - let embedding = (|| -> Option> { - let bitmap = MtmdBitmap::from_buf(mtmd, &bytes).ok()?; - let bitmap_refs = [&bitmap]; - - let text = MtmdInputText::new(MtmdContext::default_marker(), false, false); - let mut chunks = MtmdInputChunks::new(); - mtmd.tokenize(&text, &bitmap_refs, &mut chunks).ok()?; - - for chunk in chunks.iter() { - if chunk.chunk_type() == MtmdInputChunkType::Image { - mtmd.encode_chunk(&chunk).ok()?; - let n_tokens = chunk.n_tokens(); - let n_embd = model.n_embd() as usize; - let n_elements = n_tokens * n_embd; - let embd = mtmd.output_embd(n_elements); - let mut pooled = vec![0.0f32; n_embd]; - for t in 0..n_tokens { - for (d, p) in pooled.iter_mut().enumerate().take(n_embd) { - *p += embd[t * n_embd + d]; - } - } - if n_tokens > 0 { - for p in pooled.iter_mut().take(n_embd) { - *p /= n_tokens as f32; - } - } - let norm: f32 = pooled.iter().map(|x| x * x).sum::().sqrt(); - if norm > 0.0 { - for v in &mut pooled { - *v /= norm; - } - } - return Some(pooled); - } - } - None - })(); - - result_tx.send(embedding).ok(); - } else { - llm_warn!( - &app, - &log_buf, - log_file, - "EmbedImage: no mmproj loaded — returning None" - ); - result_tx.send(None).ok(); - } - } - #[cfg(not(feature = "llm-mtmd"))] - { - result_tx.send(None).ok(); - } - } - } - } - - // ── Ordered teardown ────────────────────────────────────────────────────── - // Drop ctx and model before the backend so llama.cpp contexts are fully - // released before llama_backend_free() is called. - drop(ctx); - drop(model); - // _owned_backend (Option) drops here naturally, calling - // llama_backend_free() and resetting LLAMA_BACKEND_INITIALIZED so that - // neutts TTS can re-claim the backend on the next init. - // _unowned_backend is ManuallyDrop and is never freed (we don't own it). - - llm_info!(&app, &log_buf, log_file, "actor exiting — GPU resources released"); - app.emit_event("llm:status", json!({"status":"stopped"})); -} diff --git a/crates/skill-llm/src/engine/generation.rs b/crates/skill-llm/src/engine/generation.rs deleted file mode 100644 index 9e59f843..00000000 --- a/crates/skill-llm/src/engine/generation.rs +++ /dev/null @@ -1,287 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -// Copyright (C) 2026 NeuroSkill.com -//! Text-only and multimodal generation entry points. - -use tokio::sync::mpsc::UnboundedSender; - -use llama_cpp_4::{llama_batch::LlamaBatch, model::AddBos}; - -use super::logging::{LlmLogBuffer, LlmLogFile}; -use super::protocol::{GenParams, InferToken}; -use super::sampling::run_sampling_loop; -use crate::event::LlmEventEmitter; - -/// GPU memory safety thresholds (configurable via LlmConfig). -#[derive(Clone, Copy, Debug)] -pub(super) struct GpuMemoryGuard { - /// Minimum free GB before starting a decode pass. - pub decode_threshold: f64, - /// Minimum free GB during token-by-token generation. - pub gen_threshold: f64, -} - -/// Check whether the system has enough free GPU/unified memory to safely run -/// a Metal/CUDA decode pass. Returns `(ok, free_gb)` — `ok` is `true` when -/// we either cannot determine memory (optimistic) or when at least -/// `min_free_gb` is available. -pub(super) fn gpu_memory_check(min_free_gb: f64) -> (bool, Option) { - let Some(gpu) = skill_data::gpu_stats::read() else { - return (true, None); - }; - let free_gb = gpu.free_memory_bytes.map(|b| b as f64 / (1024.0 * 1024.0 * 1024.0)); - let ok = free_gb.is_none_or(|f| f >= min_free_gb); - (ok, free_gb) -} - -// ── Text-only generation ─────────────────────────────────────────────────────── - -#[allow(clippy::too_many_arguments)] -pub(super) fn run_generation( - model: &llama_cpp_4::model::LlamaModel, - ctx: &mut llama_cpp_4::context::LlamaContext<'_>, - app: &dyn LlmEventEmitter, - log_buf: &LlmLogBuffer, - log_file: Option<&LlmLogFile>, - prompt: String, - params: GenParams, - token_tx: UnboundedSender, - gpu_guard: GpuMemoryGuard, -) { - ctx.clear_kv_cache(); - - // When thinking is disabled, pre-fill an empty \n\n\n block. - let prompt = if params.thinking_budget == Some(0) { - format!("{prompt}\n\n\n") - } else { - prompt - }; - - let Ok(tokens) = model.str_to_token(&prompt, AddBos::Always) else { - token_tx.send(InferToken::Error("tokenization failed".into())).ok(); - return; - }; - let n_prompt = tokens.len(); - let n_ctx = ctx.n_ctx() as usize; - - llm_info!( - app, - log_buf, - log_file, - "prompt: {n_prompt} tokens, thinking_budget={:?}", - params.thinking_budget - ); - if n_prompt >= n_ctx { - let msg = format!("prompt too long ({n_prompt} ≥ n_ctx {n_ctx})"); - llm_warn!(app, log_buf, log_file, "{msg}"); - token_tx.send(InferToken::Error(msg)).ok(); - return; - } - - // Guard: verify enough GPU memory is available before starting decode. - // Metal's ggml backend will call abort() on allocation failure, which - // kills the entire process. By checking early we can return a - // recoverable error instead. - let (mem_ok, free_gb) = gpu_memory_check(gpu_guard.decode_threshold); - if !mem_ok { - let msg = format!( - "Insufficient GPU memory for decode ({:.2} GB free, {:.2} GB required). \ - Reduce context size, close other GPU apps, or adjust the GPU memory threshold in Settings → LLM.", - free_gb.unwrap_or(0.0), - gpu_guard.decode_threshold, - ); - llm_error!(app, log_buf, log_file, "{msg}"); - token_tx.send(InferToken::Error(msg)).ok(); - return; - } - - let n_batch = ctx.n_batch() as usize; - let mut i = 0; - while i < n_prompt { - let end = (i + n_batch).min(n_prompt); - let mut batch = LlamaBatch::new(end - i, 1); - for (j, &token) in tokens.iter().enumerate().take(end).skip(i) { - let logits = j == n_prompt - 1; - if batch.add(token, j as i32, &[0], logits).is_err() { - break; - } - } - if ctx.decode(&mut batch).is_err() { - // Metal on macOS can transiently fail (GPU busy, command buffer - // timeout). Clear the KV cache and retry the entire prompt once - // before giving up. - llm_warn!( - app, - log_buf, - log_file, - "decode failed on prompt batch at token {i} — retrying after KV cache reset" - ); - std::thread::sleep(std::time::Duration::from_millis(100)); - ctx.clear_kv_cache(); - - // Rebuild from token 0 so KV state is consistent. - let mut retry_ok = true; - let mut ri = 0; - while ri < n_prompt { - let rend = (ri + n_batch).min(n_prompt); - let mut rb = LlamaBatch::new(rend - ri, 1); - for (j, &token) in tokens.iter().enumerate().take(rend).skip(ri) { - let logits = j == n_prompt - 1; - if rb.add(token, j as i32, &[0], logits).is_err() { - break; - } - } - if ctx.decode(&mut rb).is_err() { - retry_ok = false; - break; - } - ri = rend; - } - if !retry_ok { - llm_error!( - app, - log_buf, - log_file, - "decode error on prompt (batch at token {i}) — retry also failed" - ); - token_tx - .send(InferToken::Error( - "Decode error — the GPU failed to process the prompt. \ - Try sending the message again, or restart the model in Settings → LLM." - .into(), - )) - .ok(); - return; - } - // Retry succeeded — break out of the outer loop since we - // already processed the entire prompt. - llm_info!(app, log_buf, log_file, "prompt decode succeeded on retry"); - break; - } - i = end; - } - - run_sampling_loop( - model, ctx, app, log_buf, log_file, ¶ms, token_tx, n_prompt, gpu_guard, - ); -} - -// ── Multimodal generation (llm-mtmd feature) ────────────────────────────────── - -#[cfg(feature = "llm-mtmd")] -#[allow(clippy::too_many_arguments)] -pub(super) fn run_generation_multimodal( - model: &llama_cpp_4::model::LlamaModel, - ctx: &mut llama_cpp_4::context::LlamaContext<'_>, - mtmd_ctx: &llama_cpp_4::mtmd::MtmdContext, - app: &dyn LlmEventEmitter, - log_buf: &LlmLogBuffer, - log_file: Option<&LlmLogFile>, - prompt: String, - images: Vec>, - params: GenParams, - token_tx: UnboundedSender, - gpu_guard: GpuMemoryGuard, -) { - use llama_cpp_4::mtmd::{MtmdBitmap, MtmdInputChunks, MtmdInputText}; - - ctx.clear_kv_cache(); - - let n_ctx = ctx.n_ctx() as usize; - - // When thinking is disabled, pre-fill an empty \n\n\n block. - let prompt = if params.thinking_budget == Some(0) { - format!("{prompt}\n\n\n") - } else { - prompt - }; - - // Decode raw bytes → MtmdBitmap (auto-detects JPEG/PNG/etc.) - let bitmaps: Vec = images - .iter() - .enumerate() - .filter_map(|(i, bytes)| match MtmdBitmap::from_buf(mtmd_ctx, bytes) { - Ok(b) => Some(b), - Err(e) => { - llm_warn!(app, log_buf, log_file, "image {i} decode failed: {e}"); - None - } - }) - .collect(); - - if bitmaps.is_empty() && !images.is_empty() { - token_tx - .send(InferToken::Error("all images failed to decode".into())) - .ok(); - return; - } - - llm_info!( - app, - log_buf, - log_file, - "multimodal prompt — {} image(s), thinking_budget={:?}", - bitmaps.len(), - params.thinking_budget - ); - - let bitmap_refs: Vec<&MtmdBitmap> = bitmaps.iter().collect(); - let text = MtmdInputText::new(&prompt, true, true); - let mut chunks = MtmdInputChunks::new(); - - if let Err(e) = mtmd_ctx.tokenize(&text, &bitmap_refs, &mut chunks) { - let msg = format!("mtmd tokenize error: {e}"); - llm_error!(app, log_buf, log_file, "{msg}"); - token_tx.send(InferToken::Error(msg)).ok(); - return; - } - - let n_tokens = chunks.n_tokens(); - llm_info!(app, log_buf, log_file, "prompt+images: ~{n_tokens} tokens"); - if n_tokens >= n_ctx { - let msg = format!("prompt+images too long ({n_tokens} ≥ n_ctx {n_ctx})"); - llm_warn!(app, log_buf, log_file, "{msg}"); - token_tx.send(InferToken::Error(msg)).ok(); - return; - } - - // Guard: verify enough GPU memory before multimodal decode. - let (mem_ok, free_gb) = gpu_memory_check(gpu_guard.decode_threshold); - if !mem_ok { - let msg = format!( - "Insufficient GPU memory for multimodal decode ({:.2} GB free, {:.2} GB required). \ - Reduce context size, close other GPU apps, or adjust the GPU memory threshold in Settings → LLM.", - free_gb.unwrap_or(0.0), - gpu_guard.decode_threshold, - ); - llm_error!(app, log_buf, log_file, "{msg}"); - token_tx.send(InferToken::Error(msg)).ok(); - return; - } - - let n_batch = ctx.n_batch() as i32; - let mut n_past = 0i32; - if let Err(e) = mtmd_ctx.eval_chunks(ctx.as_ptr(), &chunks, 0, 0, n_batch, true, &mut n_past) { - // Retry once after KV cache reset (transient Metal failures). - llm_warn!( - app, - log_buf, - log_file, - "mtmd eval failed: {e} — retrying after KV cache reset" - ); - std::thread::sleep(std::time::Duration::from_millis(100)); - ctx.clear_kv_cache(); - n_past = 0; - if let Err(e2) = mtmd_ctx.eval_chunks(ctx.as_ptr(), &chunks, 0, 0, n_batch, true, &mut n_past) { - let msg = format!("mtmd eval error: {e2} (retry also failed, original: {e})"); - llm_error!(app, log_buf, log_file, "{msg}"); - token_tx.send(InferToken::Error(msg)).ok(); - return; - } - llm_info!(app, log_buf, log_file, "multimodal eval succeeded on retry"); - } - - let n_prompt = n_past as usize; - run_sampling_loop( - model, ctx, app, log_buf, log_file, ¶ms, token_tx, n_prompt, gpu_guard, - ); -} diff --git a/crates/skill-llm/src/engine/init.rs b/crates/skill-llm/src/engine/init.rs index b9954b0b..fad983d8 100644 --- a/crates/skill-llm/src/engine/init.rs +++ b/crates/skill-llm/src/engine/init.rs @@ -7,7 +7,37 @@ use std::sync::{atomic::AtomicBool, Arc, Mutex}; use serde_json::json; use tokio::sync::mpsc; -use super::actor::run_actor; +// Pick the actor entry point: rlx_actor when compiled in, otherwise a +// stub that immediately emits an error so `init()` stays callable without +// feature gates at every call site. +#[cfg(feature = "llm-rlx")] +use super::rlx_actor::run_actor; + +#[cfg(not(feature = "llm-rlx"))] +#[allow(clippy::too_many_arguments)] +fn run_actor( + _rx: tokio::sync::mpsc::UnboundedReceiver, + _config: crate::config::LlmConfig, + _model_path: std::path::PathBuf, + _mmproj_path: Option, + app: std::sync::Arc, + log_buf: LlmLogBuffer, + _log_path: Option, + _ready_flag: std::sync::Arc, + _n_ctx_flag: std::sync::Arc, + _vision_flag: std::sync::Arc, +) { + push_log( + &app, + &log_buf, + "error", + "skill-llm built without a backend feature (enable llm-rlx)", + ); + app.emit_event( + "llm:status", + serde_json::json!({"status":"stopped","error":"no LLM backend compiled in"}), + ); +} use super::logging::{push_log, LlmLogBuffer}; use super::protocol::InferRequest; use super::state::LlmServerState; @@ -121,6 +151,7 @@ pub fn init( config.params_b = entry.params_b; config.quant = entry.quant.clone(); config.max_context_length = entry.max_context_length; + config.mtp_capable = entry.mtp; if config.ctx_size.is_none() { let recommended = crate::catalog::recommend_ctx_size(entry); diff --git a/crates/skill-llm/src/engine/mod.rs b/crates/skill-llm/src/engine/mod.rs index 5aa1e7f7..6386b273 100644 --- a/crates/skill-llm/src/engine/mod.rs +++ b/crates/skill-llm/src/engine/mod.rs @@ -1,21 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-only // Copyright (C) 2026 NeuroSkill.com -//! OpenAI-compatible LLM inference server — native llama-cpp-4 backend. -//! -//! # Architecture -//! -//! A dedicated OS thread ("actor") owns the `LlamaBackend`, `LlamaModel`, and -//! `LlamaContext`. Axum HTTP handlers communicate with the actor through a -//! pair of channels: -//! -//! ```text -//! axum handler ──InferRequest──▶ actor thread -//! axum handler ◀──InferToken ── actor thread (unbounded mpsc per request) -//! ``` -//! -//! This design sidesteps all `LlamaContext<'model>` lifetime issues: the actor -//! owns both the model and the context in a single scope, so lifetimes are -//! trivially satisfied. +//! OpenAI-compatible LLM inference server — RLX runtime backend. //! //! # Sub-modules //! @@ -24,19 +9,20 @@ //! | `logging` | Log buffer, file sink, push helpers | //! | `protocol` | Wire types: InferRequest, InferToken, GenParams, … | //! | `state` | LlmServerState, LlmStateCell, status helpers | -//! | `think_tracker` | `` budget enforcement | //! | `images` | Base64 data-URL decoding for chat messages | //! | `tool_orchestration` | Multi-round tool-calling loop | -//! | `sampling` | Token-by-token sampling with stop-string holdback | -//! | `generation` | Text-only and multimodal generation entry points | -//! | `actor` | The OS thread event loop | +//! | `rlx_actor` | The OS thread event loop (RLX backend) | +//! | `rlx_backend` | RLX model loading and generation | //! | `init` | Public `init()` — spawns actor, returns state | // ── Internal macros ─────────────────────────────────────────────────────────── // Defined before submodule declarations so they are in scope for all children. +#[allow(unused_macros)] macro_rules! llm_info { ($app:expr, $buf:expr, $file:expr, $($t:tt)*) => { $crate::engine::logging::push_log_inner($app, $buf, $file, "info", &format!($($t)*)) } } +#[allow(unused_macros)] macro_rules! llm_warn { ($app:expr, $buf:expr, $file:expr, $($t:tt)*) => { $crate::engine::logging::push_log_inner($app, $buf, $file, "warn", &format!($($t)*)) } } +#[allow(unused_macros)] macro_rules! llm_error { ($app:expr, $buf:expr, $file:expr, $($t:tt)*) => { $crate::engine::logging::push_log_inner($app, $buf, $file, "error", &format!($($t)*)) } } // ── Sub-modules ─────────────────────────────────────────────────────────────── @@ -45,12 +31,12 @@ pub mod logging; pub mod protocol; pub mod state; -mod actor; -mod generation; pub mod images; mod init; -mod sampling; -mod think_tracker; +#[cfg(feature = "llm-rlx")] +mod rlx_actor; +#[cfg(feature = "llm-rlx")] +mod rlx_backend; pub mod tool_orchestration; // ── Re-exports ──────────────────────────────────────────────────────────────── diff --git a/crates/skill-llm/src/engine/rlx_actor.rs b/crates/skill-llm/src/engine/rlx_actor.rs new file mode 100644 index 00000000..0b2b2c3c --- /dev/null +++ b/crates/skill-llm/src/engine/rlx_actor.rs @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! RLX inference actor — processes [`InferRequest`]s on a dedicated OS thread. +//! +//! Owns a [`RlxTextRunner`] (which wraps `Box` from rlx-models) +//! and processes [`InferRequest`]s on a dedicated OS thread. Mirrors the +//! [`actor`](super::actor) module's event-loop shape so the rest of the +//! engine machinery (init, channels, status events) stays unchanged. +//! +//! Capabilities vs the llama-cpp actor: +//! * Generate / Complete — supported (text-only). +//! * Embed — not supported (returns an error). +//! * EmbedImage — not supported (returns `None`). +//! * Health — supported. +//! +//! Chat templating uses `rlx_models::run::auto_chat_template`, which loads +//! the Jinja chat template directly from the GGUF metadata. MiniCPM5 ships +//! an agent/tooling template that relies on Python-only Jinja helpers, so +//! that family uses a simplified ChatML template instead. + +use std::path::Path; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + +use serde_json::json; + +use super::logging::{LlmLogBuffer, LlmLogFile}; +use super::protocol::{InferRequest, InferToken}; +use super::rlx_backend::RlxTextRunner; +use crate::config::LlmConfig; +use crate::event::LlmEventEmitter; + +#[allow(clippy::too_many_arguments)] +pub(super) fn run_actor( + mut rx: tokio::sync::mpsc::UnboundedReceiver, + config: LlmConfig, + model_path: std::path::PathBuf, + mmproj_path: Option, + app: Arc, + log_buf: LlmLogBuffer, + log_path: Option, + ready_flag: Arc, + n_ctx_flag: Arc, + vision_flag: Arc, +) { + let log_file_handle: Option = log_path.as_ref().and_then(|p| { + std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(p) + .ok() + .map(|f| Arc::new(Mutex::new(std::io::BufWriter::new(f)))) + }); + let log_file = log_file_handle.as_ref(); + + llm_info!( + &app, + &log_buf, + log_file, + "[rlx-only actor] loading model: {}", + model_path.display() + ); + + let mut runner = match RlxTextRunner::load_with_mmproj(&model_path, mmproj_path.as_deref(), &config) { + Ok(r) => r, + Err(e) => { + llm_error!(&app, &log_buf, log_file, "RLX runner load failed: {e}"); + app.emit_event( + "llm:status", + json!({"status":"stopped","error":format!("RLX load failed: {e}")}), + ); + return; + } + }; + + let supports_vision = runner.supports_multimodal(); + vision_flag.store(supports_vision, Ordering::Relaxed); + // No fixed n_ctx in RLX — report config-requested value (or 0). + n_ctx_flag.store(config.ctx_size.unwrap_or(0) as usize, Ordering::Relaxed); + + let family = runner.family(); + let chat_template = resolve_chat_template(&model_path, family, &app, &log_buf, log_file); + + ready_flag.store(true, Ordering::Relaxed); + let model_file = model_path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + llm_info!( + &app, + &log_buf, + log_file, + "[rlx-only actor] ready — model={} family={}", + model_file, + family + ); + app.emit_event( + "llm:status", + json!({ + "status": "running", + "model": model_file, + "runtime": "Rlx", + "supports_vision": supports_vision, + "supports_tools": true, + }), + ); + + while let Some(req) = rx.blocking_recv() { + match req { + InferRequest::Health { result_tx } => { + result_tx.send(true).ok(); + } + + InferRequest::Generate { + messages, + images, + params, + token_tx, + } => { + let prompt = render_chat(&chat_template, &messages); + let prompt = match prompt { + Ok(p) => p, + Err(e) => { + token_tx + .send(InferToken::Error(format!("chat template render failed: {e}"))) + .ok(); + continue; + } + }; + llm_info!( + &app, + &log_buf, + log_file, + "chat request — {} messages, {} image(s), max_tokens={}", + messages.len(), + images.len(), + params.max_tokens + ); + if !images.is_empty() { + if runner.supports_multimodal() { + runner.generate_multimodal(&prompt, &images, params, token_tx); + } else { + token_tx + .send(InferToken::Error( + "RLX runtime: this model has no mmproj vision encoder \ + attached — load an mmproj GGUF or use a text-only model" + .into(), + )) + .ok(); + } + continue; + } + runner.generate(&prompt, params, token_tx); + } + + InferRequest::Complete { + prompt, + params, + token_tx, + } => { + llm_info!( + &app, + &log_buf, + log_file, + "completion request — max_tokens={}", + params.max_tokens + ); + runner.generate(&prompt, params, token_tx); + } + + InferRequest::Embed { result_tx, .. } => { + result_tx + .send(Err(anyhow::anyhow!( + "embeddings are not supported by the RLX-only actor" + ))) + .ok(); + } + + InferRequest::EmbedImage { result_tx, .. } => { + result_tx.send(None).ok(); + } + } + } + + drop(runner); + llm_info!(&app, &log_buf, log_file, "[rlx-only actor] exiting — runner dropped"); + app.emit_event("llm:status", json!({"status":"stopped"})); +} + +/// MiniCPM5 GGUF metadata embeds a tool-agent Jinja template that calls +/// Python string methods (`startswith`, …) unsupported by minijinja. Use a +/// ChatML subset that matches plain chat + generation without tools. +fn minicpm5_chat_template(model_path: &Path) -> anyhow::Result { + use rlx_models::run::ChatTemplate; + let im_end = format!("<|{}|>", "im_end"); + let source = format!( + "{{{{ bos_token }}}}{{%- for m in messages -%}}\ + {{%- if m.role == 'system' -%}}\ + <|im_start|>system\n{{{{ m.content }}}}{im_end}\n\ + {{%- elif m.role == 'user' or (m.role == 'system' and not loop.first) -%}}\ + <|im_start|>{{{{ m.role }}}}\n{{{{ m.content }}}}{im_end}\n\ + {{%- elif m.role == 'assistant' -%}}\ + <|im_start|>assistant\n{{{{ m.content }}}}{im_end}\n\ + {{%- endif -%}}\ + {{%- endfor -%}}\ + {{%- if add_generation_prompt -%}}\ + <|im_start|>assistant\n\ + {{%- endif -%}}", + im_end = im_end, + ); + let mut tpl = ChatTemplate::from_source(source)?; + if let Ok(gguf) = ChatTemplate::from_gguf(model_path) { + tpl = tpl.with_tokens( + gguf.bos_token().map(str::to_string), + gguf.eos_token().map(str::to_string), + ); + } + Ok(tpl) +} + +fn resolve_chat_template( + model_path: &Path, + family: &str, + app: &Arc, + log_buf: &LlmLogBuffer, + log_file: Option<&LlmLogFile>, +) -> Option { + if family == "minicpm5" { + match minicpm5_chat_template(model_path) { + Ok(t) => { + llm_info!( + app, + log_buf, + log_file, + "MiniCPM5: using simplified ChatML template (GGUF agent template skipped)" + ); + return Some(t); + } + Err(e) => { + llm_warn!( + app, + log_buf, + log_file, + "MiniCPM5 chat template setup failed ({e}) — trying GGUF metadata" + ); + } + } + } + + match rlx_models::run::auto_chat_template(model_path) { + Ok(t) => Some(t), + Err(e) => { + llm_warn!( + app, + log_buf, + log_file, + "no chat template available ({e}) — falling back to simple role-tagged concat" + ); + None + } + } +} + +/// Render a list of `{"role","content"}` chat messages via the resolved +/// chat template. Falls back to a simple `": \n"` concat +/// when no template was loaded. +fn render_chat( + template: &Option, + messages: &[serde_json::Value], +) -> anyhow::Result { + use rlx_models::run::ChatMessage; + let msgs: Vec = messages + .iter() + .map(|m| { + let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("user").to_string(); + // `content` may be a string or an array of parts; we + // only join the textual parts (rlx is text-only here). + let content = match m.get("content") { + Some(serde_json::Value::String(s)) => s.clone(), + Some(serde_json::Value::Array(parts)) => parts + .iter() + .filter_map(|p| { + if let Some(t) = p.get("text").and_then(|t| t.as_str()) { + Some(t.to_string()) + } else if let Some(s) = p.as_str() { + Some(s.to_string()) + } else { + None + } + }) + .collect::>() + .join("\n"), + _ => String::new(), + }; + ChatMessage { role, content } + }) + .collect(); + if let Some(tpl) = template { + tpl.render(&msgs, true).or_else(|_| simple_render_chat(&msgs)) + } else { + simple_render_chat(&msgs) + } +} + +fn simple_render_chat(msgs: &[rlx_models::run::ChatMessage]) -> anyhow::Result { + let mut out = String::new(); + for m in msgs { + out.push_str(&format!("{}: {}\n", m.role, m.content)); + } + out.push_str("assistant: "); + Ok(out) +} diff --git a/crates/skill-llm/src/engine/rlx_backend.rs b/crates/skill-llm/src/engine/rlx_backend.rs new file mode 100644 index 00000000..917377dc --- /dev/null +++ b/crates/skill-llm/src/engine/rlx_backend.rs @@ -0,0 +1,594 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! RLX text-generation backend — fully independent of llama-cpp. +//! +//! Routes through [`rlx_models::run::auto_runner_with_mmproj`] for the +//! families it knows (Qwen3 / Qwen3.5 / Qwen3.6 incl. MTP, Llama32-shaped +//! stacks, LFM2.5). Catalog families that need an explicit builder before +//! the generic auto path — Gemma 3/4, MiniCPM5, MiniMax M2.x, Nemotron-H +//! — are wired below via per-family runners. +//! +//! Uses [`rlx_models::run::auto_tokenize`] and [`auto_detokenize`] +//! for prompt encoding / streaming decode — no native C++ dependency. + +use anyhow::{anyhow, Result}; +use rlx::gguf::{GgufFile, MetaValue}; +use rlx_models::run::{auto_detokenize, auto_runner_with_mmproj, auto_tokenize}; +use rlx_models::LmRunner; +use std::path::{Path, PathBuf}; +use tokio::sync::mpsc::UnboundedSender; + +use super::protocol::{GenParams, InferToken}; +use crate::config::LlmConfig; + +fn peek_gguf_arch(path: &Path) -> Option { + let raw = GgufFile::from_path(path).ok()?; + raw.metadata + .get("general.architecture") + .and_then(MetaValue::as_str) + .map(str::to_string) +} + +fn filename_hint(path: &Path, needle: &str) -> bool { + path.to_string_lossy().to_ascii_lowercase().contains(needle) +} + +fn argmax_u32(logits: &[f32]) -> u32 { + let mut best = 0usize; + let mut best_v = f32::NEG_INFINITY; + for (i, &v) in logits.iter().enumerate() { + if v > best_v { + best_v = v; + best = i; + } + } + best as u32 +} + +/// Thin [`LmRunner`] adapter over [`rlx_minicpm5::MiniCpm5Runner`]. +struct MiniCpm5LmRunner(rlx_minicpm5::MiniCpm5Runner); + +impl LmRunner for MiniCpm5LmRunner { + fn family(&self) -> &'static str { + "minicpm5" + } + + fn vocab_size(&self) -> usize { + self.0.llama_config().vocab_size + } + + fn predict_logits(&mut self, prompt_ids: &[u32]) -> Result> { + self.0.predict_logits(prompt_ids) + } + + fn generate( + &mut self, + prompt_ids: &[u32], + n_new: usize, + on_token: &mut dyn FnMut(u32) -> bool, + ) -> Result> { + self.0.generate(prompt_ids, n_new, |tok| { + let _ = on_token(tok); + }) + } +} + +/// Thin [`LmRunner`] adapter over [`rlx_minimax::MiniMaxRunner`]. +struct MiniMaxLmRunner(rlx_minimax::MiniMaxRunner); + +impl LmRunner for MiniMaxLmRunner { + fn family(&self) -> &'static str { + "minimax" + } + + fn vocab_size(&self) -> usize { + self.0.config().vocab_size + } + + fn predict_logits(&mut self, prompt_ids: &[u32]) -> Result> { + if prompt_ids.is_empty() { + return Err(anyhow!("MiniMaxLmRunner::predict_logits: empty prompt")); + } + self.0.reset_state(); + let mut last = Vec::new(); + for &t in prompt_ids { + last = self.0.step(t); + } + Ok(last) + } + + fn generate( + &mut self, + prompt_ids: &[u32], + n_new: usize, + on_token: &mut dyn FnMut(u32) -> bool, + ) -> Result> { + self.0.reset_state(); + let mut last = Vec::new(); + for &t in prompt_ids { + last = self.0.step(t); + } + let mut out = Vec::with_capacity(n_new); + for _ in 0..n_new { + let next = argmax_u32(&last); + out.push(next); + if !on_token(next) { + break; + } + last = self.0.step(next); + } + Ok(out) + } +} + +/// Thin [`LmRunner`] adapter over [`rlx_nemotron::NemotronHybridRunner`]. +struct NemotronHybridLmRunner(rlx_nemotron::NemotronHybridRunner); + +impl LmRunner for NemotronHybridLmRunner { + fn family(&self) -> &'static str { + "nemotron_h" + } + + fn vocab_size(&self) -> usize { + self.0.config().vocab_size + } + + fn predict_logits(&mut self, prompt_ids: &[u32]) -> Result> { + if prompt_ids.is_empty() { + return Err(anyhow!("NemotronHybridLmRunner::predict_logits: empty prompt")); + } + self.0.reset_state(); + let mut last = Vec::new(); + for &t in prompt_ids { + last = self.0.step(t); + } + Ok(last) + } + + fn generate( + &mut self, + prompt_ids: &[u32], + n_new: usize, + on_token: &mut dyn FnMut(u32) -> bool, + ) -> Result> { + self.0.reset_state(); + let mut last = Vec::new(); + for &t in prompt_ids { + last = self.0.step(t); + } + let mut out = Vec::with_capacity(n_new); + for _ in 0..n_new { + let next = argmax_u32(&last); + out.push(next); + if !on_token(next) { + break; + } + last = self.0.step(next); + } + Ok(out) + } +} + +/// Dense `nemotron` arch — delegates to the inner [`Llama32Runner`]. +struct NemotronLmRunner(rlx_nemotron::NemotronRunner); + +impl LmRunner for NemotronLmRunner { + fn family(&self) -> &'static str { + "nemotron" + } + + fn vocab_size(&self) -> usize { + self.0.config().vocab_size + } + + fn predict_logits(&mut self, prompt_ids: &[u32]) -> Result> { + LmRunner::predict_logits(self.0.inner_mut(), prompt_ids) + } + + fn generate( + &mut self, + prompt_ids: &[u32], + n_new: usize, + on_token: &mut dyn FnMut(u32) -> bool, + ) -> Result> { + LmRunner::generate(self.0.inner_mut(), prompt_ids, n_new, on_token) + } +} + +fn looks_like_minicpm5(path: &Path) -> bool { + if filename_hint(path, "minicpm5") || filename_hint(path, "minicpm-5") { + return true; + } + let Ok(cfg) = rlx_minicpm5::config::llama_config_from_hf(path) else { + return false; + }; + let preset = rlx_minicpm5::config::minicpm5_1b_preset(); + cfg.hidden_size == preset.hidden_size + && cfg.num_hidden_layers == preset.num_hidden_layers + && cfg.vocab_size == preset.vocab_size +} + +fn looks_like_minimax(path: &Path) -> bool { + if filename_hint(path, "minimax") { + return true; + } + matches!( + peek_gguf_arch(path).as_deref(), + Some("minimax" | "minimax-m2" | "minimax_m2") + ) +} + +fn looks_like_nemotron(path: &Path) -> bool { + if filename_hint(path, "nemotron") { + return true; + } + matches!( + peek_gguf_arch(path).as_deref(), + Some("nemotron" | "nemotron_h" | "nemotron_h_moe" | "nemotron-h") + ) +} + +fn looks_like_gemma(path: &Path) -> bool { + if filename_hint(path, "gemma") { + return true; + } + matches!( + peek_gguf_arch(path).as_deref(), + Some( + "gemma" + | "gemma2" + | "gemma3" + | "gemma3n" + | "gemma4" + | "gemma4moe" + | "gemma4_unified" + | "gemma4_unified_text" + ) + ) +} + +fn try_minicpm5_runner(path: &Path) -> Option>> { + if !looks_like_minicpm5(path) { + return None; + } + Some( + rlx_minicpm5::MiniCpm5Runner::builder() + .weights(path) + .build() + .map(|runner| Box::new(MiniCpm5LmRunner(runner)) as Box), + ) +} + +fn try_minimax_runner(path: &Path) -> Option>> { + if !looks_like_minimax(path) { + return None; + } + Some( + rlx_minimax::MiniMaxRunner::builder() + .weights(path) + .build() + .map(|runner| Box::new(MiniMaxLmRunner(runner)) as Box), + ) +} + +fn try_nemotron_runner(path: &Path) -> Option>> { + if !looks_like_nemotron(path) { + return None; + } + let arch = peek_gguf_arch(path); + if matches!(arch.as_deref(), Some("nemotron_h" | "nemotron_h_moe" | "nemotron-h")) { + return Some( + rlx_nemotron::NemotronHybridRunner::builder() + .weights(path) + .build() + .map(|runner| Box::new(NemotronHybridLmRunner(runner)) as Box), + ); + } + if arch.as_deref() == Some("nemotron") { + return Some( + rlx_nemotron::NemotronRunner::builder() + .weights(path) + .build() + .map(|runner| Box::new(NemotronLmRunner(runner)) as Box), + ); + } + // Filename hinted nemotron but arch unknown — try hybrid first, then dense. + Some( + rlx_nemotron::NemotronHybridRunner::builder() + .weights(path) + .build() + .map(|runner| Box::new(NemotronHybridLmRunner(runner)) as Box) + .or_else(|_| { + rlx_nemotron::NemotronRunner::builder() + .weights(path) + .build() + .map(|runner| Box::new(NemotronLmRunner(runner)) as Box) + }), + ) +} + +fn try_gemma_runner(path: &Path) -> Option>> { + if !looks_like_gemma(path) { + return None; + } + Some( + rlx_gemma::GemmaRunner::builder() + .weights(path) + .build() + .map(|runner| Box::new(runner) as Box), + ) +} + +fn try_catalog_runner(path: &Path) -> Option>> { + try_gemma_runner(path) + .or_else(|| try_minicpm5_runner(path)) + .or_else(|| try_minimax_runner(path)) + .or_else(|| try_nemotron_runner(path)) +} + +fn resolve_gemma_tokenizer(weights: &Path) -> Option { + if let Ok(raw) = std::env::var("GEMMA_TOKENIZER") { + let p = PathBuf::from(raw); + if p.is_file() { + return Some(p); + } + } + rlx_gemma::resolve_tokenizer_path(weights, None) +} + +fn resolve_minicpm5_tokenizer(weights: &Path) -> Option { + if let Ok(raw) = std::env::var("MINICPM5_TOKENIZER") { + let p = PathBuf::from(raw); + if p.is_file() { + return Some(p); + } + } + if let Some(parent) = weights.parent() { + let sibling = parent.join("tokenizer.json"); + if sibling.is_file() { + return Some(sibling); + } + } + use hf_hub::{Cache, Repo}; + let cache = Cache::from_env(); + cache + .repo(Repo::model("openbmb/MiniCPM5-1B".to_string())) + .get("tokenizer.json") +} + +pub(super) struct RlxTextRunner { + runner: Box, + family: &'static str, + weights_path: PathBuf, + explicit_tokenizer: Option, +} + +impl RlxTextRunner { + pub(super) fn load(model_path: &Path, _config: &LlmConfig) -> Result { + Self::load_with_mmproj(model_path, None, _config) + } + + /// Like [`load`] but attaches an mmproj vision encoder (e.g. for + /// Qwen3.5-VL). When `mmproj` is `None` the runner is text-only. + pub(super) fn load_with_mmproj(model_path: &Path, mmproj: Option<&Path>, _config: &LlmConfig) -> Result { + if let Some(result) = try_catalog_runner(model_path) { + let runner = result?; + let family = runner.family(); + let explicit_tokenizer = match family { + "gemma" => resolve_gemma_tokenizer(model_path), + "minicpm5" => resolve_minicpm5_tokenizer(model_path), + _ => None, + }; + return Ok(Self { + runner, + family, + weights_path: model_path.to_path_buf(), + explicit_tokenizer, + }); + } + + let runner = auto_runner_with_mmproj(model_path, mmproj).map_err(|e| anyhow!("RLX auto_runner: {e}"))?; + let family = runner.family(); + Ok(Self { + runner, + family, + weights_path: model_path.to_path_buf(), + explicit_tokenizer: None, + }) + } + + pub(super) fn family(&self) -> &'static str { + self.family + } + + pub(super) fn supports_multimodal(&self) -> bool { + self.runner.supports_multimodal() + } + + pub(super) fn generate(&mut self, prompt: &str, params: GenParams, token_tx: UnboundedSender) { + let prompt_ids = match auto_tokenize(&self.weights_path, prompt, self.explicit_tokenizer.as_deref()) { + Ok(ids) => ids, + Err(e) => { + token_tx + .send(InferToken::Error(format!("RLX tokenization failed: {e}"))) + .ok(); + return; + } + }; + if prompt_ids.is_empty() { + token_tx + .send(InferToken::Error( + "RLX prompt tokenization returned no usable tokens".into(), + )) + .ok(); + return; + } + + // Streaming decode strategy: keep all generated ids, decode the + // full vector each step, emit only the suffix that wasn't sent + // before. This handles multi-byte UTF-8 codepoints split across + // byte-level BPE tokens correctly (decoding ids individually + // would emit broken codepoints). O(n²) in token count but + // negligible for typical max_tokens (≤2048). + let mut all_ids: Vec = Vec::with_capacity(params.max_tokens.min(4096)); + let mut emitted_len: usize = 0; + let mut completion_tokens = 0usize; + let max_tokens = params.max_tokens; + let stop = params.stop.clone(); + let stop_for_cb = stop.clone(); + let weights = self.weights_path.clone(); + let explicit = self.explicit_tokenizer.clone(); + let token_tx_inner = token_tx.clone(); + // Track the full accumulated text for stop-string matching. + let mut accumulated_text = String::new(); + + let mut on_token = |tok: u32| -> bool { + completion_tokens += 1; + all_ids.push(tok); + // Decode the full sequence and emit the new suffix. + let decoded = match auto_detokenize(&weights, &all_ids, explicit.as_deref(), true) { + Ok(s) => s, + Err(_) => return true, + }; + if decoded.len() > emitted_len { + let piece = decoded[emitted_len..].to_string(); + emitted_len = decoded.len(); + if !piece.is_empty() { + accumulated_text.push_str(&piece); + token_tx_inner.send(InferToken::Delta(piece)).ok(); + } + } + for s in &stop_for_cb { + if !s.is_empty() && accumulated_text.ends_with(s) { + return false; + } + } + true + }; + + let result = self + .runner + .generate(&prompt_ids, max_tokens, &mut on_token as &mut dyn FnMut(u32) -> bool); + + if let Err(e) = result { + token_tx + .send(InferToken::Error(format!("RLX generation failed: {e}"))) + .ok(); + return; + } + + let finish_reason = if stop.iter().any(|s| !s.is_empty() && accumulated_text.ends_with(s)) { + "stop" + } else { + "length" + }; + token_tx + .send(InferToken::Done { + finish_reason: finish_reason.into(), + prompt_tokens: prompt_ids.len(), + completion_tokens, + n_ctx: prompt_ids.len().saturating_add(completion_tokens), + }) + .ok(); + } + + /// Multimodal generation — decodes the first image to RGB and + /// hands it off to the runner's [`LmRunner::generate_multimodal`] + /// (currently the Qwen3.5 family path). Additional images beyond + /// the first are ignored (matches llama-cpp's first-image behaviour + /// for single-frame chat). Streams decoded text via `token_tx`. + pub(super) fn generate_multimodal( + &mut self, + prompt: &str, + images: &[Vec], + params: GenParams, + token_tx: UnboundedSender, + ) { + if !self.runner.supports_multimodal() { + token_tx + .send(InferToken::Error( + "this RLX model has no mmproj vision encoder attached".into(), + )) + .ok(); + return; + } + let Some(first) = images.first() else { + // Empty image list — fall back to text-only path. + return self.generate(prompt, params, token_tx); + }; + let img = match image::load_from_memory(first) { + Ok(i) => i.to_rgb8(), + Err(e) => { + token_tx + .send(InferToken::Error(format!("RLX image decode failed: {e}"))) + .ok(); + return; + } + }; + let (img_w, img_h) = (img.width() as usize, img.height() as usize); + let rgb = img.into_raw(); + + let max_tokens = params.max_tokens; + let stop = params.stop.clone(); + let stop_for_cb = stop.clone(); + let mut completion_tokens = 0usize; + let mut all_ids: Vec = Vec::with_capacity(max_tokens.min(4096)); + let mut emitted_len: usize = 0; + let mut accumulated_text = String::new(); + let weights = self.weights_path.clone(); + let explicit = self.explicit_tokenizer.clone(); + let token_tx_inner = token_tx.clone(); + + let mut on_token = |tok: u32| -> bool { + completion_tokens += 1; + all_ids.push(tok); + let decoded = match auto_detokenize(&weights, &all_ids, explicit.as_deref(), true) { + Ok(s) => s, + Err(_) => return true, + }; + if decoded.len() > emitted_len { + let piece = decoded[emitted_len..].to_string(); + emitted_len = decoded.len(); + if !piece.is_empty() { + accumulated_text.push_str(&piece); + token_tx_inner.send(InferToken::Delta(piece)).ok(); + } + } + for s in &stop_for_cb { + if !s.is_empty() && accumulated_text.ends_with(s) { + return false; + } + } + true + }; + + let result = self.runner.generate_multimodal( + prompt, + &rgb, + img_w, + img_h, + self.explicit_tokenizer.as_deref(), + max_tokens, + &mut on_token as &mut dyn FnMut(u32) -> bool, + ); + if let Err(e) = result { + token_tx + .send(InferToken::Error(format!("RLX multimodal generation failed: {e}"))) + .ok(); + return; + } + let finish_reason = if stop.iter().any(|s| !s.is_empty() && accumulated_text.ends_with(s)) { + "stop" + } else { + "length" + }; + token_tx + .send(InferToken::Done { + finish_reason: finish_reason.into(), + prompt_tokens: 0, + completion_tokens, + n_ctx: completion_tokens, + }) + .ok(); + } +} diff --git a/crates/skill-llm/src/engine/sampling.rs b/crates/skill-llm/src/engine/sampling.rs deleted file mode 100644 index 94084fbb..00000000 --- a/crates/skill-llm/src/engine/sampling.rs +++ /dev/null @@ -1,229 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -// Copyright (C) 2026 NeuroSkill.com -//! Token-by-token sampling loop with stop-string hold-back buffer. - -use tokio::sync::mpsc::UnboundedSender; - -use llama_cpp_4::{ - llama_batch::LlamaBatch, - model::{AddBos, Special}, - sampling::LlamaSampler, -}; - -use super::generation::GpuMemoryGuard; -use super::logging::{LlmLogBuffer, LlmLogFile}; -use super::protocol::{GenParams, InferToken}; -use super::think_tracker::ThinkTracker; -use crate::event::LlmEventEmitter; - -/// Run the token-by-token generation loop starting at `n_prompt` KV positions. -/// -/// Precondition: the KV cache already contains the fully-decoded prompt (text -/// or text+images) and the logits for the last prompt position are valid. -/// `sampler.sample(ctx, -1)` samples from those logits. -#[allow(clippy::too_many_arguments)] -pub(super) fn run_sampling_loop( - model: &llama_cpp_4::model::LlamaModel, - ctx: &mut llama_cpp_4::context::LlamaContext<'_>, - app: &dyn LlmEventEmitter, - log_buf: &LlmLogBuffer, - log_file: Option<&LlmLogFile>, - params: &GenParams, - token_tx: UnboundedSender, - n_prompt: usize, - gpu_guard: GpuMemoryGuard, -) { - let n_ctx = ctx.n_ctx() as usize; - let n_batch = ctx.n_batch() as usize; - let _ = n_batch; // available for future use - - let mut sampler = LlamaSampler::chain_simple([ - LlamaSampler::top_k(params.top_k), - LlamaSampler::top_p(params.top_p, 1), - LlamaSampler::temp(params.temperature), - LlamaSampler::dist(params.seed), - ]); - - // Stop strings: user-supplied + model-family defaults. - let mut stop_strings = params.stop.clone(); - for s in &[ - "<|im_end|>", - "<|endoftext|>", - "<|user|>", - "<|eot_id|>", - "<|EOT|>", - "[/INST]", - ] { - if !stop_strings.iter().any(|x| x == s) { - stop_strings.push(s.to_string()); - } - } - let max_stop_len = stop_strings.iter().map(std::string::String::len).max().unwrap_or(0); - let hold_back = max_stop_len.saturating_sub(1); - - // Think-budget tracker (budget=0 is handled before this call; None = unlimited) - let tracker_budget = match params.thinking_budget { - Some(0) | None => None, - Some(n) => Some(n), - }; - let mut think_tracker = ThinkTracker::new(tracker_budget); - - let max_new = params.max_tokens.min(n_ctx.saturating_sub(n_prompt)); - let mut n_cur = n_prompt; - let mut finish_reason = "length".to_string(); - let mut pending = String::new(); - // After a forced injection, discard tokens (still decoded into KV - // cache for coherence) until the model reaches a clean line break. - let mut discard_until_nl = false; - - 'gen: loop { - if n_cur >= n_prompt + max_new { - break; - } - - // -1 = "last token that had logits computed" - let token = sampler.sample(ctx, -1); - sampler.accept(token); - - if model.is_eog_token(token) { - finish_reason = "stop".to_string(); - break; - } - - let piece = model.token_to_str(token, Special::Plaintext).unwrap_or_default(); - - // After forced injection: decode token into KV cache for - // coherence, but suppress it from the output stream. - if discard_until_nl { - if piece.contains('\n') { - discard_until_nl = false; - } - let mut b = LlamaBatch::new(1, 1); - b.add(token, n_cur as i32, &[0], true).ok(); - if ctx.decode(&mut b).is_err() { - token_tx.send(InferToken::Error("decode error".into())).ok(); - break; - } - n_cur += 1; - continue; - } - - // Think-budget enforcement: inject when budget exhausted. - if let Some(inject) = think_tracker.feed(&piece) { - token_tx.send(InferToken::Delta(inject.clone())).ok(); - - if let Ok(inj_toks) = model.str_to_token(&inject, AddBos::Never) { - if !inj_toks.is_empty() { - let mut inj_batch = LlamaBatch::new(inj_toks.len(), 1); - for (i, &t) in inj_toks.iter().enumerate() { - inj_batch - .add(t, n_cur as i32 + i as i32, &[0], i == inj_toks.len() - 1) - .ok(); - } - if ctx.decode(&mut inj_batch).is_err() { - llm_warn!(app, log_buf, log_file, "decode error injecting "); - } - n_cur += inj_toks.len(); - } - } - discard_until_nl = true; - let mut b = LlamaBatch::new(1, 1); - b.add(token, n_cur as i32, &[0], true).ok(); - if ctx.decode(&mut b).is_err() { - token_tx.send(InferToken::Error("decode error".into())).ok(); - break; - } - n_cur += 1; - continue; - } - - pending.push_str(&piece); - - // Check for stop strings. - for stop in &stop_strings { - if pending.ends_with(stop.as_str()) { - let safe_end = pending.len().saturating_sub(stop.len()); - if safe_end > 0 { - token_tx.send(InferToken::Delta(pending[..safe_end].to_string())).ok(); - } - finish_reason = "stop".to_string(); - break 'gen; - } - } - - // Emit safe prefix (hold back potential partial stop string). - if pending.len() > hold_back { - let emit_end = pending.len() - hold_back; - let emit_end = (0..=emit_end).rev().find(|&i| pending.is_char_boundary(i)).unwrap_or(0); - if emit_end > 0 { - let chunk: String = pending.drain(..emit_end).collect(); - if token_tx.send(InferToken::Delta(chunk)).is_err() { - break; - } - } - } - - // Decode the new token so `sampler.sample(ctx, -1)` works next iteration. - // Periodic GPU memory check (every 64 tokens) to avoid Metal abort(). - if n_cur.is_multiple_of(64) && gpu_guard.gen_threshold > 0.0 { - let (mem_ok, free_gb) = super::generation::gpu_memory_check(gpu_guard.gen_threshold); - if !mem_ok { - llm_warn!( - app, - log_buf, - log_file, - "stopping generation — GPU memory critically low ({:.2} GB free < {:.2} GB threshold)", - free_gb.unwrap_or(0.0), - gpu_guard.gen_threshold - ); - token_tx - .send(InferToken::Delta(format!( - "\n\n*[Generation stopped: GPU memory low ({:.2} GB free). \ - Adjust threshold in Settings → LLM.]*", - free_gb.unwrap_or(0.0) - ))) - .ok(); - finish_reason = "gpu_memory".to_string(); - break; - } - } - let mut gen_batch = LlamaBatch::new(1, 1); - if gen_batch.add(token, n_cur as i32, &[0], true).is_err() { - break; - } - if ctx.decode(&mut gen_batch).is_err() { - token_tx.send(InferToken::Error("decode error".into())).ok(); - break; - } - n_cur += 1; - } - - // Flush hold-back buffer, trimming any trailing stop string. - let flush_end = stop_strings - .iter() - .find_map(|s| { - pending - .ends_with(s.as_str()) - .then_some(pending.len().saturating_sub(s.len())) - }) - .unwrap_or(pending.len()); - if flush_end > 0 { - token_tx.send(InferToken::Delta(pending[..flush_end].to_string())).ok(); - } - - let n_gen = n_cur.saturating_sub(n_prompt); - llm_info!( - app, - log_buf, - log_file, - "generation done — prompt={n_prompt} completion={n_gen} ctx={n_ctx} finish={finish_reason}" - ); - token_tx - .send(InferToken::Done { - finish_reason, - prompt_tokens: n_prompt, - completion_tokens: n_gen, - n_ctx, - }) - .ok(); -} diff --git a/crates/skill-llm/src/engine/think_tracker.rs b/crates/skill-llm/src/engine/think_tracker.rs deleted file mode 100644 index e485a03f..00000000 --- a/crates/skill-llm/src/engine/think_tracker.rs +++ /dev/null @@ -1,158 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -// Copyright (C) 2026 NeuroSkill.com -//! `` budget tracker for reasoning models. - -/// Tracks the model's `` block and enforces a token budget. -/// -/// Feed every decoded piece via `feed()`. When the budget is exhausted the -/// method returns `Some("\n\n")` — that string should be: -/// 1. Appended to the outgoing `pending` buffer (so the UI sees it), and -/// 2. Tokenised and decoded into the KV cache (so the model continues from -/// a logically consistent state after the closing tag). -pub(super) struct ThinkTracker { - budget: Option, - inside: bool, - closed: bool, - tag_buf: String, // accumulate chars to detect multi-token tags - tok_count: u32, -} - -impl ThinkTracker { - pub fn new(budget: Option) -> Self { - Self { - budget, - inside: false, - closed: false, - tag_buf: String::new(), - tok_count: 0, - } - } - - /// Returns `Some(inject)` if the think block must be force-closed now. - pub fn feed(&mut self, piece: &str) -> Option { - if self.closed { - return None; - } - - self.tag_buf.push_str(piece); - // Keep tag_buf bounded — only need enough to detect the longest tag - let cap = "".len() + 4; - if self.tag_buf.len() > cap * 2 { - let drain = self.tag_buf.len() - cap; - // Snap to a char boundary — raw byte arithmetic can land inside a - // multi-byte codepoint (e.g. CJK) and cause a panic. - let drain = (0..=drain) - .rev() - .find(|&i| self.tag_buf.is_char_boundary(i)) - .unwrap_or(0); - self.tag_buf.drain(..drain); - } - - if !self.inside { - // Detect opening - if self.tag_buf.contains("") { - self.inside = true; - // Trim everything up to and including the opening tag - if let Some(p) = self.tag_buf.find("") { - self.tag_buf = self.tag_buf[p + 7..].to_string(); - } - } - return None; - } - - // Inside the think block - self.tok_count += 1; - - // Check for natural close - if self.tag_buf.contains("") { - self.inside = false; - self.closed = true; - self.tag_buf.clear(); - return None; - } - - // Enforce budget - if let Some(budget) = self.budget { - if self.tok_count >= budget { - self.inside = false; - self.closed = true; - self.tag_buf.clear(); - return Some("\n\n".to_string()); - } - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn no_think_block_returns_none() { - let mut t = ThinkTracker::new(Some(100)); - assert!(t.feed("Hello world").is_none()); - assert!(t.feed("No thinking here.").is_none()); - } - - #[test] - fn natural_close_within_budget() { - let mut t = ThinkTracker::new(Some(100)); - assert!(t.feed("").is_none()); - assert!(t.feed("reasoning...").is_none()); - assert!(t.feed("").is_none()); - // After close, further feeds should return None - assert!(t.feed("more text").is_none()); - } - - #[test] - fn budget_exceeded_injects_close() { - let mut t = ThinkTracker::new(Some(3)); - assert!(t.feed("").is_none()); - assert!(t.feed("tok1").is_none()); // count=1 - assert!(t.feed("tok2").is_none()); // count=2 - let inject = t.feed("tok3"); // count=3 = budget - assert_eq!(inject, Some("\n\n".to_string())); - // After forced close, no more injections - assert!(t.feed("tok4").is_none()); - } - - #[test] - fn unlimited_budget_never_injects() { - let mut t = ThinkTracker::new(None); - assert!(t.feed("").is_none()); - for i in 0..1000 { - assert!(t.feed(&format!("tok{i}")).is_none()); - } - } - - #[test] - fn split_tags_across_pieces() { - let mut t = ThinkTracker::new(Some(5)); - assert!(t.feed("").is_none()); // now inside - assert!(t.feed("reasoning").is_none()); // count=1 - assert!(t.feed("").is_none()); // natural close detected - // Should be closed now - assert!(t.feed("after").is_none()); - } - - #[test] - fn zero_budget_means_no_tracker() { - // Budget of None (which is what budget=0 maps to upstream) - let mut t = ThinkTracker::new(None); - assert!(t.feed("").is_none()); - assert!(t.feed("tok").is_none()); - } - - #[test] - fn multibyte_chars_dont_panic() { - let mut t = ThinkTracker::new(Some(50)); - assert!(t.feed("").is_none()); - // Feed lots of CJK chars to exercise the tag_buf drain boundary logic - for _ in 0..30 { - assert!(t.feed("\u{4e16}\u{754c}\u{4f60}\u{597d}").is_none()); - } - } -} diff --git a/crates/skill-llm/src/lib.rs b/crates/skill-llm/src/lib.rs index 4c2af4ea..29bbb62e 100644 --- a/crates/skill-llm/src/lib.rs +++ b/crates/skill-llm/src/lib.rs @@ -24,12 +24,14 @@ pub mod log; /// ``` /// /// Short-circuits (no `format!` allocation) when logging is disabled. -#[allow(unused_macros)] macro_rules! llm_log { ($tag:expr, $($arg:tt)*) => { - if $crate::log::log_enabled() { - $crate::log::write_log($tag, &format!($($arg)*)); - } + ::skill_constants::subsystem_log!( + $crate::log::log_enabled, + $crate::log::write_log, + $tag, + $($arg)* + ); }; } @@ -46,7 +48,7 @@ pub mod engine; pub mod handlers; // Re-export the most-used types at crate root for convenience. -pub use config::{LlmConfig, LlmToolConfig, ToolExecutionMode}; +pub use config::{LlmConfig, LlmInferenceRuntime, LlmToolConfig, ToolExecutionMode}; pub use event::{LlmEventEmitter, NoopEmitter}; #[cfg(feature = "llm")] diff --git a/crates/skill-llm/tests/llm_mtp_e2e.rs b/crates/skill-llm/tests/llm_mtp_e2e.rs new file mode 100644 index 00000000..782acd29 --- /dev/null +++ b/crates/skill-llm/tests/llm_mtp_e2e.rs @@ -0,0 +1,268 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! +//! End-to-end MTP (Multi-Token Prediction) integration test. +//! +//! Runs the full pipeline against a cached MTP-capable GGUF (the +//! `qwen36-27b-mtp` catalog family — e.g. `Qwen3.6-27B-Q4_K_M-mtp.gguf` from +//! `froggeric/Qwen3.6-27B-MTP-GGUF`). Verifies that the spec-decode loop +//! activates and reports a non-zero draft acceptance rate. +//! +//! **Skip-friendly:** if no MTP-capable model is cached locally (no HF +//! cache hit), the test logs a clear skip-warning and exits OK. This is the +//! same offline-only pattern as `llm_e2e.rs`. Set up the cache once via: +//! +//! huggingface-cli download froggeric/Qwen3.6-27B-MTP-GGUF \ +//! Qwen3.6-27B-Q4_K_M-mtp.gguf +//! +//! Run with: +//! cargo test -p skill-llm --features llm --test llm_mtp_e2e -- --nocapture + +#![cfg(feature = "llm")] + +use std::sync::{atomic::Ordering, Arc}; +use std::time::{Duration, Instant}; + +use serde_json::json; + +use skill_llm::catalog::{DownloadState, LlmCatalog, LlmModelEntry}; +use skill_llm::config::LlmConfig; +use skill_llm::engine::protocol::GenParams; +use skill_llm::{init, new_log_buffer, LlmEventEmitter, NoopEmitter}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Find the smallest cached MTP-capable model (entry with `mtp == true` AND +/// resolves to an HF cache hit). Excludes mmproj files. +fn best_cached_mtp_model(catalog: &LlmCatalog) -> Option<&LlmModelEntry> { + let mut cached: Vec<&LlmModelEntry> = catalog + .entries + .iter() + .filter(|e| e.mtp && !e.is_mmproj() && e.resolve_cached().is_some()) + .collect(); + cached.sort_by(|a, b| a.size_gb.total_cmp(&b.size_gb)); + cached.first().copied() +} + +fn wait_ready(state: &skill_llm::LlmServerState, timeout: Duration) -> bool { + let start = Instant::now(); + while !state.is_ready() { + if start.elapsed() > timeout { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } + true +} + +async fn collect_tokens( + mut rx: tokio::sync::mpsc::UnboundedReceiver, +) -> Result<(String, String, usize, usize, usize), String> { + let mut text = String::new(); + let (mut fr, mut pt, mut ct, mut nc) = (String::new(), 0usize, 0usize, 0usize); + while let Some(tok) = rx.recv().await { + match tok { + skill_llm::InferToken::Delta(t) => text.push_str(&t), + skill_llm::InferToken::Done { + finish_reason, + prompt_tokens, + completion_tokens, + n_ctx, + } => { + fr = finish_reason; + pt = prompt_tokens; + ct = completion_tokens; + nc = n_ctx; + break; + } + skill_llm::InferToken::Error(e) => return Err(e), + } + } + Ok((text, fr, pt, ct, nc)) +} + +/// Concatenate every log entry's message — used to grep for `[mtp]` lines. +fn log_dump(log_buf: &skill_llm::LlmLogBuffer) -> String { + let guard = log_buf.lock().expect("log buf poisoned"); + guard + .iter() + .map(|e| format!("[{}] {}", e.level, e.message)) + .collect::>() + .join("\n") +} + +/// Parse `accepted=N (X.Y%)` out of the `[mtp] generation done` line. +fn parse_mtp_summary(logs: &str) -> Option<(u64, u64, u64, f64)> { + let line = logs.lines().rev().find(|l| l.contains("[mtp] generation done"))?; + let kv = |key: &str| -> Option { + let i = line.find(key)?; + let rest = &line[i + key.len()..]; + let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); + rest[..end].parse().ok() + }; + let rounds = kv("rounds=")?; + let drafts = kv("drafts=")?; + let accepted = kv("accepted=")?; + let pct_start = line.find('(')?; + let pct_end = line[pct_start..].find('%')?; + let pct = line[pct_start + 1..pct_start + pct_end].parse::().ok()?; + Some((rounds, drafts, accepted, pct)) +} + +// ── Test ───────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn mtp_e2e_spec_decode_loop() { + eprintln!(); + eprintln!("╔══════════════════════════════════════════════════════════════════════════════╗"); + eprintln!("║ MTP E2E Integration Test — spec-decode pipeline ║"); + eprintln!("╚══════════════════════════════════════════════════════════════════════════════╝"); + eprintln!(); + + // ── 1. Temp skill_dir ───────────────────────────────────────────────── + let skill_dir = std::env::temp_dir().join(format!("skill-mtp-e2e-{}", std::process::id())); + let _ = std::fs::create_dir_all(&skill_dir); + eprintln!("[1] skill_dir = {}", skill_dir.display()); + + // ── 2. Find a cached MTP-capable model ───────────────────────────────── + let mut catalog = LlmCatalog::load(&skill_dir); + let Some(entry) = best_cached_mtp_model(&catalog).cloned() else { + eprintln!( + "[2] ⚠️ SKIP — no MTP-capable model cached locally.\n\ + Cache one with:\n \ + huggingface-cli download froggeric/Qwen3.6-27B-MTP-GGUF \\\n \ + Qwen3.6-27B-Q4_K_M-mtp.gguf" + ); + let _ = std::fs::remove_dir_all(&skill_dir); + return; + }; + eprintln!( + "[2] selected MTP model: {} ({:.2} GB, quant={}, family={})", + entry.filename, entry.size_gb, entry.quant, entry.family_name + ); + + // ── 3. Wire the catalog as if the user picked this model ────────────── + let local_path = entry + .resolve_cached() + .expect("MTP entry should resolve from local HF cache"); + eprintln!("[3] cache hit → {}", local_path.display()); + if let Some(e) = catalog.entries.iter_mut().find(|e| e.filename == entry.filename) { + e.state = DownloadState::Downloaded; + e.local_path = Some(local_path.clone()); + } + catalog.active_model = entry.filename.clone(); + + // ── 4. Start LLM server with MTP enabled ────────────────────────────── + let t = Instant::now(); + let config = LlmConfig { + enabled: true, + n_gpu_layers: u32::MAX, + ctx_size: Some(2048), + // The v0.2.53 fork benchmark found =1 was the sweet spot on Q4_K_M + // (+6.2% throughput vs baseline). =3 regressed for that quant. + mtp_draft_count: 1, + ..LlmConfig::default() + }; + let emitter: Arc = Arc::new(NoopEmitter); + let log_buf = new_log_buffer(); + + eprintln!("[4] starting LLM server (mtp_draft_count={}) …", config.mtp_draft_count); + let server = + init(&config, &catalog, emitter, log_buf.clone(), &skill_dir).expect("init should return a running server"); + let readied = wait_ready(&server, Duration::from_secs(180)); + let load_dur = t.elapsed(); + + if !readied { + eprintln!("[4] ❌ server failed to reach ready within 180s"); + let logs = log_dump(&log_buf); + eprintln!("--- log dump ---\n{logs}\n----------------"); + panic!("server not ready"); + } + let n_ctx = server.n_ctx.load(Ordering::Relaxed); + eprintln!("[4] ✅ ready in {:.2}s — n_ctx={n_ctx}", load_dur.as_secs_f64()); + + // ── 5. Assert the MTP smoke validation fired and succeeded ──────────── + let logs = log_dump(&log_buf); + let smoke_ok = logs.contains("[mtp] draft heads present"); + let smoke_fail = logs.contains("[mtp] draft heads missing"); + if smoke_fail { + eprintln!( + "[5] ❌ MTP smoke validation failed — the GGUF was flagged catalog \ + `mtp:true` but the runtime could not build a Mtp context. \ + Either the GGUF is stale or mis-flagged." + ); + eprintln!("--- log dump ---\n{logs}\n----------------"); + panic!("MTP smoke validation failed at load time"); + } + assert!(smoke_ok, "expected '[mtp] draft heads present' in logs, got:\n{logs}"); + eprintln!("[5] ✅ smoke validation: draft heads present"); + + // ── 6. Send a short generation request (text-only, no images) ───────── + eprintln!("[6] sending short generation request …"); + let msgs = vec![ + json!({"role": "system", "content": "You are a helpful assistant. Answer concisely."}), + json!({"role": "user", "content": "Name three colors of the rainbow. Just the words, one per line."}), + ]; + let params = GenParams { + max_tokens: 32, + // Temperature 0 (greedy-like) gives MTP its best chance — drafts are + // most likely to match deterministic sampling. + temperature: 0.0, + thinking_budget: Some(0), + ..GenParams::default() + }; + let gen_start = Instant::now(); + let rx = server.chat(msgs, vec![], params).expect("chat accepted"); + let (text, fr, pt, ct, nc) = collect_tokens(rx).await.expect("generation ok"); + let gen_dur = gen_start.elapsed(); + let tps = if gen_dur.as_secs_f64() > 0.0 { + ct as f64 / gen_dur.as_secs_f64() + } else { + 0.0 + }; + eprintln!( + "[6] response ({:.2}s, {:.1} tok/s, finish={fr}, prompt={pt}, completion={ct}, n_ctx={nc}):", + gen_dur.as_secs_f64(), + tps + ); + for line in text.lines() { + eprintln!("[6] | {line}"); + } + assert!(!text.trim().is_empty(), "MTP generation produced empty text"); + assert!(ct > 0, "MTP generation produced zero completion tokens"); + + // ── 7. Verify the spec-decode loop actually ran ─────────────────────── + let logs = log_dump(&log_buf); + let Some((rounds, drafts, accepted, pct)) = parse_mtp_summary(&logs) else { + eprintln!("--- log dump ---\n{logs}\n----------------"); + panic!("'[mtp] generation done' summary line not found in logs"); + }; + eprintln!("[7] ✅ MTP loop ran — rounds={rounds} drafts={drafts} accepted={accepted} ({pct:.1}%)"); + assert!(rounds > 0, "MTP loop reported zero rounds — dispatch likely fell back"); + assert!(drafts > 0, "MTP loop reported zero drafts proposed"); + // Accepted may legitimately be 0 on a single short prompt — we don't + // assert a minimum acceptance rate, just that the machinery ran. + + // ── 8. Shutdown ─────────────────────────────────────────────────────── + let t = Instant::now(); + match Arc::try_unwrap(server) { + Ok(owned) => owned.shutdown(), + Err(arc) => drop(arc), + } + eprintln!("[8] shutdown ({:.2}s)", t.elapsed().as_secs_f64()); + + let _ = std::fs::remove_dir_all(&skill_dir); + + eprintln!(); + eprintln!("╔══════════════════════════════════════════════════════════════════════════════╗"); + eprintln!("║ ✅ MTP E2E PASSED — {rounds} rounds, {accepted}/{drafts} drafts accepted ({pct:.1}%) "); + eprintln!( + "║ load: {:.2}s · gen: {:.2}s · {:.1} tok/s · {ct} completion tokens", + load_dur.as_secs_f64(), + gen_dur.as_secs_f64(), + tps + ); + eprintln!("╚══════════════════════════════════════════════════════════════════════════════╝"); + eprintln!(); +} diff --git a/crates/skill-llm/tests/minicpm5_e2e.rs b/crates/skill-llm/tests/minicpm5_e2e.rs new file mode 100644 index 00000000..abace577 --- /dev/null +++ b/crates/skill-llm/tests/minicpm5_e2e.rs @@ -0,0 +1,201 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! +//! MiniCPM5-1B integration test via `rlx-minicpm5` through skill-llm. +//! +//! Run with: +//! cargo test -p skill-llm --features llm-rlx-cpu --test minicpm5_e2e -- --nocapture +//! +//! Optional env: +//! MINICPM5_WEIGHTS=/path/to/MiniCPM5-1B-Q4_K_M.gguf + +#![cfg(feature = "llm-rlx")] + +use std::path::{Path, PathBuf}; +use std::sync::{atomic::Ordering, Arc}; +use std::time::{Duration, Instant}; + +use serde_json::json; +use skill_llm::catalog::{DownloadState, LlmCatalog}; +use skill_llm::config::LlmConfig; +use skill_llm::engine::protocol::GenParams; +use skill_llm::{init, new_log_buffer, LlmEventEmitter, NoopEmitter}; + +fn minicpm5_weights_path(entry: &skill_llm::catalog::LlmModelEntry) -> Option { + if let Ok(raw) = std::env::var("MINICPM5_WEIGHTS") { + let p = PathBuf::from(raw); + if p.is_file() { + return Some(p); + } + } + + if let Some(p) = entry.resolve_cached() { + if p.is_file() { + return Some(p); + } + } + + for candidate in [ + "/tmp/rlx-weights/MiniCPM5-1B-GGUF/MiniCPM5-1B-Q4_K_M.gguf", + "/tmp/rlx-weights/MiniCPM5-1B-GGUF/MiniCPM5-1B-Q8_0.gguf", + ] { + let p = PathBuf::from(candidate); + if p.is_file() { + return Some(p); + } + } + + None +} + +fn wait_ready(state: &skill_llm::LlmServerState, timeout: Duration) { + let start = Instant::now(); + while !state.is_ready() { + if start.elapsed() > timeout { + panic!("LLM server not ready within {:.0}s", timeout.as_secs_f64()); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +async fn collect_tokens( + mut rx: tokio::sync::mpsc::UnboundedReceiver, +) -> Result<(String, String, usize, usize), String> { + let mut text = String::new(); + let mut fr = String::new(); + let (mut pt, mut ct) = (0, 0); + while let Some(tok) = rx.recv().await { + match tok { + skill_llm::InferToken::Delta(t) => text.push_str(&t), + skill_llm::InferToken::Done { + finish_reason, + prompt_tokens, + completion_tokens, + .. + } => { + fr = finish_reason; + pt = prompt_tokens; + ct = completion_tokens; + break; + } + skill_llm::InferToken::Error(e) => return Err(e), + } + } + Ok((text, fr, pt, ct)) +} + +#[test] +fn bundled_catalog_includes_minicpm5() { + let skill_dir = std::env::temp_dir().join(format!("skill-minicpm5-catalog-{}", std::process::id())); + let _ = std::fs::create_dir_all(&skill_dir); + let catalog = LlmCatalog::load(&skill_dir); + let _ = std::fs::remove_dir_all(&skill_dir); + + let family_entries: Vec<_> = catalog + .entries + .iter() + .filter(|e| e.family_id == "minicpm5-1b") + .collect(); + assert!( + !family_entries.is_empty(), + "bundled catalog should include minicpm5-1b entries" + ); + assert!( + family_entries.iter().any(|e| e.filename == "MiniCPM5-1B-Q4_K_M.gguf"), + "expected Q4_K_M quant in catalog" + ); + assert_eq!(family_entries[0].family_name, "MiniCPM5 1B".to_string()); + assert_eq!(family_entries[0].repo, "openbmb/MiniCPM5-1B-GGUF".to_string()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn minicpm5_load_and_chat() { + let skill_dir = std::env::temp_dir().join(format!("skill-minicpm5-e2e-{}", std::process::id())); + let _ = std::fs::create_dir_all(&skill_dir); + + let mut catalog = LlmCatalog::load(&skill_dir); + let Some(entry) = catalog + .entries + .iter() + .find(|e| e.family_id == "minicpm5-1b" && e.filename == "MiniCPM5-1B-Q4_K_M.gguf") + .cloned() + else { + panic!("MiniCPM5-1B-Q4_K_M.gguf missing from bundled catalog"); + }; + + let Some(weights) = minicpm5_weights_path(&entry) else { + eprintln!("skip: MiniCPM5 weights not found — set MINICPM5_WEIGHTS or cache openbmb/MiniCPM5-1B-GGUF"); + let _ = std::fs::remove_dir_all(&skill_dir); + return; + }; + + eprintln!("[minicpm5] weights: {}", weights.display()); + assert!( + looks_like_minicpm5_path(&weights), + "weight path should match MiniCPM5 heuristics" + ); + + if let Some(e) = catalog.entries.iter_mut().find(|e| e.filename == entry.filename) { + e.state = DownloadState::Downloaded; + e.local_path = Some(weights.clone()); + } + catalog.active_model = entry.filename.clone(); + + let config = LlmConfig { + enabled: true, + n_gpu_layers: u32::MAX, + ctx_size: Some(2048), + ..LlmConfig::default() + }; + let emitter: Arc = Arc::new(NoopEmitter); + let log_buf = new_log_buffer(); + + let load_start = Instant::now(); + let server = init(&config, &catalog, emitter, log_buf, &skill_dir).expect("init MiniCPM5 server"); + wait_ready(&server, Duration::from_secs(180)); + eprintln!( + "[minicpm5] server ready in {:.2}s (n_ctx={})", + load_start.elapsed().as_secs_f64(), + server.n_ctx.load(Ordering::Relaxed) + ); + + let msgs = vec![ + json!({"role": "system", "content": "You are a helpful assistant. Answer concisely."}), + json!({"role": "user", "content": "What is 2+2? Reply with only the number."}), + ]; + let params = GenParams { + max_tokens: 8, + temperature: 0.0, + thinking_budget: Some(0), + ..GenParams::default() + }; + + let gen_start = Instant::now(); + let rx = server.chat(msgs, vec![], params).expect("chat accepted"); + let (text, finish, pt, ct) = collect_tokens(rx).await.expect("generation ok"); + eprintln!( + "[minicpm5] response ({:.2}s): finish={finish} prompt={pt} completion={ct} text={text:?}", + gen_start.elapsed().as_secs_f64() + ); + + match Arc::try_unwrap(server) { + Ok(owned) => owned.shutdown(), + Err(arc) => drop(arc), + }; + let _ = std::fs::remove_dir_all(&skill_dir); + + assert!(pt > 0, "expected prompt to tokenize (got prompt_tokens={pt})"); + assert!(ct > 0, "expected model to emit tokens (got completion_tokens={ct})"); + assert!( + finish == "length" || finish == "stop", + "unexpected finish_reason: {finish}" + ); + // Note: coherent answers on GGUF+CPU require upstream rlx-minicpm5 parity work; + // this test validates skill-llm wiring (catalog → init → chat → minicpm5 family). +} + +fn looks_like_minicpm5_path(path: &Path) -> bool { + let lossy = path.to_string_lossy().to_ascii_lowercase(); + lossy.contains("minicpm5") || lossy.contains("minicpm-5") +} diff --git a/crates/skill-lsl/Cargo.toml b/crates/skill-lsl/Cargo.toml index 96b83e03..fde93218 100644 --- a/crates/skill-lsl/Cargo.toml +++ b/crates/skill-lsl/Cargo.toml @@ -11,12 +11,12 @@ skill-devices = { path = "../skill-devices" } # LSL core + iroh bridge (published on crates.io) rlsl = "0.0.4" -rlsl-iroh = "0.0.4" +rlsl-iroh = "0.0.5" tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } async-trait = "0.1" log = "0.4" -iroh = "0.97" +iroh = "1.0.0-rc.0" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/crates/skill-neutts/Cargo.toml b/crates/skill-neutts/Cargo.toml new file mode 100644 index 00000000..17dd16ba --- /dev/null +++ b/crates/skill-neutts/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "skill-neutts" +version = "0.0.1" +edition = "2021" +license = "GPL-3.0-only" + +[lib] +name = "neutts" +crate-type = ["rlib"] + +[features] +default = [] +espeak = ["dep:espeak-ng"] +metal = ["rlx-neutts/metal"] +mlx = ["rlx-neutts/mlx"] +cuda = ["rlx-neutts/cuda"] +rocm = ["rlx-neutts/rocm"] +gpu = ["rlx-neutts/gpu"] + +[dependencies] +anyhow = { workspace = true } +rlx-neutts = { version = "0.2.5", default-features = false, features = ["codec", "llama"] } +hound = "3" +sha2 = "0.10" +dirs = "6" +zip = { workspace = true } +safetensors = "0.5" +once_cell = "1" +fancy-regex = "0.14" +espeak-ng = { version = "0.1.2", features = ["bundled-data"], optional = true } + +[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] +hf-hub = { version = "0.5", default-features = false, features = ["ureq"] } + +[lints] +workspace = true diff --git a/crates/skill-neutts/src/cache.rs b/crates/skill-neutts/src/cache.rs new file mode 100644 index 00000000..4ec1ddfa --- /dev/null +++ b/crates/skill-neutts/src/cache.rs @@ -0,0 +1,201 @@ +//! Reference-code cache — avoids re-encoding the same WAV file twice. +//! +//! [`RefCodeCache`] uses the SHA-256 hash of the WAV file's raw bytes as a +//! cache key. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; + +use crate::npy; +use crate::NeuCodecEncoder; + +/// Disk cache for pre-encoded NeuCodec reference codes. +pub struct RefCodeCache { + dir: PathBuf, +} + +impl RefCodeCache { + /// Create a cache backed by the platform default cache directory. + pub fn new() -> Result { + let base = dirs::cache_dir().unwrap_or_else(|| PathBuf::from(".neutts_cache")); + Self::with_dir(base.join("neutts").join("ref_codes")) + } + + /// Create a cache backed by a specific directory. + pub fn with_dir(dir: impl Into) -> Result { + let dir = dir.into(); + std::fs::create_dir_all(&dir).with_context(|| format!("Cannot create cache directory: {}", dir.display()))?; + Ok(Self { dir }) + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + pub fn cache_path_for(&self, wav_path: &Path) -> Result { + let hash = sha256_file(wav_path)?; + Ok(self.dir.join(format!("{hash}.npy"))) + } + + pub fn is_cached(&self, wav_path: &Path) -> Result { + let path = self.cache_path_for(wav_path)?; + Ok(path.exists()) + } + + pub fn try_load(&self, wav_path: &Path) -> Result, CacheOutcome)>> { + let hash = sha256_file(wav_path).with_context(|| format!("Failed to hash: {}", wav_path.display()))?; + let cache_file = self.dir.join(format!("{hash}.npy")); + + if cache_file.exists() { + let codes = npy::load_npy_i32(&cache_file) + .with_context(|| format!("Failed to load cached codes: {}", cache_file.display()))?; + Ok(Some((codes, CacheOutcome::Hit { path: cache_file, hash }))) + } else { + Ok(None) + } + } + + pub fn store(&self, wav_path: &Path, codes: &[i32]) -> Result { + let hash = sha256_file(wav_path).with_context(|| format!("Failed to hash: {}", wav_path.display()))?; + let cache_file = self.dir.join(format!("{hash}.npy")); + npy::write_npy_i32(&cache_file, codes) + .with_context(|| format!("Failed to write cache: {}", cache_file.display()))?; + Ok(CacheOutcome::Miss { path: cache_file, hash }) + } + + pub fn get_or_encode(&self, wav_path: &Path, encoder: &NeuCodecEncoder) -> Result<(Vec, CacheOutcome)> { + if let Some(hit) = self.try_load(wav_path)? { + return Ok(hit); + } + let codes = encoder + .encode_wav(wav_path) + .with_context(|| format!("Failed to encode: {}", wav_path.display()))?; + let outcome = self.store(wav_path, &codes)?; + Ok((codes, outcome)) + } + + pub fn evict(&self, wav_path: &Path) -> Result { + let path = self.cache_path_for(wav_path)?; + if path.exists() { + std::fs::remove_file(&path).with_context(|| format!("Failed to evict cache entry: {}", path.display()))?; + Ok(true) + } else { + Ok(false) + } + } + + pub fn clear(&self) -> Result { + let mut count = 0; + for entry in + std::fs::read_dir(&self.dir).with_context(|| format!("Cannot read cache dir: {}", self.dir.display()))? + { + let entry = entry.context("Failed to read dir entry")?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("npy") { + std::fs::remove_file(&path).with_context(|| format!("Failed to remove: {}", path.display()))?; + count += 1; + } + } + Ok(count) + } +} + +/// Result of a [`RefCodeCache::get_or_encode`] call. +#[derive(Debug, Clone)] +pub enum CacheOutcome { + Hit { path: PathBuf, hash: String }, + Miss { path: PathBuf, hash: String }, +} + +impl CacheOutcome { + pub fn is_hit(&self) -> bool { + matches!(self, Self::Hit { .. }) + } + + pub fn path(&self) -> &Path { + match self { + Self::Hit { path, .. } | Self::Miss { path, .. } => path, + } + } + + pub fn hash(&self) -> &str { + match self { + Self::Hit { hash, .. } | Self::Miss { hash, .. } => hash, + } + } +} + +impl std::fmt::Display for CacheOutcome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Hit { hash, path } => write!(f, "cache hit (sha256: {}…) ← {}", &hash[..16], path.display()), + Self::Miss { hash, path } => write!(f, "cache miss (sha256: {}…) → {}", &hash[..16], path.display()), + } + } +} + +/// Compute the SHA-256 hex digest of a file's raw bytes (streaming 64 KiB buffer). +pub fn sha256_file(path: &Path) -> Result { + let mut file = + std::fs::File::open(path).with_context(|| format!("Cannot open file for hashing: {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 65_536]; + loop { + let n = file + .read(&mut buf) + .with_context(|| format!("IO error while hashing: {}", path.display()))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn tmp_dir() -> PathBuf { + let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("skill_neutts_cache_test_{}_{}", std::process::id(), n)); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn test_sha256_deterministic() { + let dir = tmp_dir(); + let path = dir.join("test.bin"); + std::fs::write(&path, b"hello neutts").unwrap(); + let h1 = sha256_file(&path).unwrap(); + let h2 = sha256_file(&path).unwrap(); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); + } + + #[test] + fn test_store_then_load() { + let dir = tmp_dir(); + let cache = RefCodeCache::with_dir(&dir).unwrap(); + let wav = dir.join("ref.wav"); + std::fs::write(&wav, b"fake wav content 123").unwrap(); + + assert!(cache.try_load(&wav).unwrap().is_none()); + + let codes: Vec = vec![1, 2, 3, 42, 1023]; + let outcome = cache.store(&wav, &codes).unwrap(); + assert!(!outcome.is_hit()); + + let (loaded, outcome2) = cache.try_load(&wav).unwrap().unwrap(); + assert!(outcome2.is_hit()); + assert_eq!(loaded, codes); + assert_eq!(outcome.path(), outcome2.path()); + } +} diff --git a/crates/skill-neutts/src/download.rs b/crates/skill-neutts/src/download.rs new file mode 100644 index 00000000..228bc5cb --- /dev/null +++ b/crates/skill-neutts/src/download.rs @@ -0,0 +1,1040 @@ +//! HuggingFace Hub model downloader and NeuCodec checkpoint converter. +//! +//! Downloads (or reuses cached copies of) the GGUF backbone from HuggingFace, +//! then constructs and returns a [`NeuTTS`](crate::model::NeuTTS). +//! +//! Files are cached under `~/.cache/huggingface/hub`; subsequent calls return +//! immediately from cache without a network request. + +#![allow(dead_code)] + +use std::path::PathBuf; + +use anyhow::{bail, Context, Result}; +use hf_hub::{api::sync::Api, api::Progress, Cache, Repo}; + +use crate::model::NeuTTS; + +// ───────────────────────────────────────────────────────────────────────────── +// Model registry +// ───────────────────────────────────────────────────────────────────────────── + +/// Metadata for a single backbone repository. +#[derive(Debug, Clone)] +pub struct ModelInfo { + pub repo: &'static str, + pub name: &'static str, + pub language: &'static str, + pub params: &'static str, + pub is_gguf: bool, + pub size_mb: u32, + pub pros: &'static str, + pub cons: &'static str, + pub recommended: bool, +} + +pub const BACKBONE_MODELS: &[ModelInfo] = &[ + ModelInfo { + repo: "neuphonic/neutts-nano-q4-gguf", + name: "NeuTTS Nano Q4", + language: "en-us", + params: "0.2B", + is_gguf: true, + size_mb: 135, + pros: "Fast CPU inference · small download · low RAM usage", + cons: "Slightly lower quality than Q8; may clip on complex sentences", + recommended: true, + }, + ModelInfo { + repo: "neuphonic/neutts-nano-q8-gguf", + name: "NeuTTS Nano Q8", + language: "en-us", + params: "0.2B", + is_gguf: true, + size_mb: 230, + pros: "Better voice quality than Q4 · still fast on modern CPUs", + cons: "2× larger download than Q4; needs ~500 MB RAM", + recommended: false, + }, + ModelInfo { + repo: "neuphonic/neutts-nano", + name: "NeuTTS Nano (full fp16)", + language: "en-us", + params: "0.2B", + is_gguf: false, + size_mb: 430, + pros: "Reference-quality for Nano; best baseline for fine-tuning", + cons: "Slowest of the Nano variants; requires FP16 build", + recommended: false, + }, + ModelInfo { + repo: "neuphonic/neutts-air-q4-gguf", + name: "NeuTTS Air Q4", + language: "en-us", + params: "0.7B", + is_gguf: true, + size_mb: 430, + pros: "High naturalness · richer prosody than Nano · voice cloning", + cons: "3× heavier than Nano Q4; slower on older hardware; ~900 MB RAM", + recommended: false, + }, + ModelInfo { + repo: "neuphonic/neutts-air-q8-gguf", + name: "NeuTTS Air Q8", + language: "en-us", + params: "0.7B", + is_gguf: true, + size_mb: 820, + pros: "Near-lossless quality for the 0.7B model", + cons: "Large download (~820 MB); needs ~1.5 GB RAM", + recommended: false, + }, + ModelInfo { + repo: "neuphonic/neutts-air", + name: "NeuTTS Air (full fp16)", + language: "en-us", + params: "0.7B", + is_gguf: false, + size_mb: 1450, + pros: "Highest possible quality for on-device English TTS", + cons: "Very large (~1.5 GB); slow on CPU; requires FP16 build", + recommended: false, + }, + ModelInfo { + repo: "neuphonic/neutts-nano-german-q4-gguf", + name: "NeuTTS Nano German Q4", + language: "de", + params: "0.2B", + is_gguf: true, + size_mb: 135, + pros: "Compact German TTS · fast CPU inference", + cons: "Q4 quantisation; lower quality than Q8", + recommended: true, + }, + ModelInfo { + repo: "neuphonic/neutts-nano-german-q8-gguf", + name: "NeuTTS Nano German Q8", + language: "de", + params: "0.2B", + is_gguf: true, + size_mb: 230, + pros: "Better German voice quality than Q4", + cons: "2× larger download", + recommended: false, + }, + ModelInfo { + repo: "neuphonic/neutts-nano-french-q4-gguf", + name: "NeuTTS Nano French Q4", + language: "fr-fr", + params: "0.2B", + is_gguf: true, + size_mb: 135, + pros: "Compact French TTS · fast CPU inference", + cons: "Q4 quantisation; lower quality than Q8", + recommended: true, + }, + ModelInfo { + repo: "neuphonic/neutts-nano-french-q8-gguf", + name: "NeuTTS Nano French Q8", + language: "fr-fr", + params: "0.2B", + is_gguf: true, + size_mb: 230, + pros: "Better French voice quality than Q4", + cons: "2× larger download", + recommended: false, + }, + ModelInfo { + repo: "neuphonic/neutts-nano-spanish-q4-gguf", + name: "NeuTTS Nano Spanish Q4", + language: "es", + params: "0.2B", + is_gguf: true, + size_mb: 135, + pros: "Compact Spanish TTS · fast CPU inference", + cons: "Q4 quantisation; lower quality than Q8", + recommended: true, + }, + ModelInfo { + repo: "neuphonic/neutts-nano-spanish-q8-gguf", + name: "NeuTTS Nano Spanish Q8", + language: "es", + params: "0.2B", + is_gguf: true, + size_mb: 230, + pros: "Better Spanish voice quality than Q4", + cons: "2× larger download", + recommended: false, + }, +]; + +pub fn find_model(repo: &str) -> Option<&'static ModelInfo> { + BACKBONE_MODELS.iter().find(|m| m.repo == repo) +} + +fn backbone_language(repo: &str) -> &'static str { + find_model(repo).map(|m| m.language).unwrap_or("en-us") +} + +// ───────────────────────────────────────────────────────────────────────────── +// Progress reporting +// ───────────────────────────────────────────────────────────────────────────── + +/// Progress event emitted during model loading. +#[derive(Debug, Clone)] +pub enum LoadProgress { + Fetching { + step: u32, + total: u32, + file: String, + repo: String, + size_mb: Option, + }, + Downloading { + step: u32, + total: u32, + downloaded: u64, + total_bytes: u64, + }, + Loading { + step: u32, + total: u32, + component: String, + }, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Download helpers +// ───────────────────────────────────────────────────────────────────────────── + +struct HfProgress { + on_bytes: F, + downloaded: u64, + total: u64, +} + +impl Progress for HfProgress { + fn init(&mut self, size: usize, _filename: &str) { + self.total = size as u64; + (self.on_bytes)(0, self.total); + } + fn update(&mut self, size: usize) { + self.downloaded += size as u64; + (self.on_bytes)(self.downloaded, self.total); + } + fn finish(&mut self) { + (self.on_bytes)(self.total, self.total); + } +} + +fn hf_download_cb(api: &Api, repo_id: &str, filename: &str, mut on_bytes: F) -> Result { + let cache_repo = Cache::from_env().repo(Repo::model(repo_id.to_string())); + if let Some(path) = cache_repo.get(filename) { + on_bytes(1, 1); + return Ok(path); + } + let api_repo = api.model(repo_id.to_string()); + let progress = HfProgress { + on_bytes, + downloaded: 0, + total: 0, + }; + api_repo + .download_with_progress(filename, progress) + .with_context(|| format!("Failed to download '{filename}' from '{repo_id}'")) +} + +fn hf_download(api: &Api, repo_id: &str, filename: &str) -> Result { + hf_download_cb(api, repo_id, filename, |_, _| {}) +} + +fn hf_list_files(api: &Api, repo_id: &str) -> Result> { + let repo = api.model(repo_id.to_string()); + let info = repo + .info() + .with_context(|| format!("Failed to fetch repo info for '{repo_id}'"))?; + Ok(info.siblings.into_iter().map(|s| s.rfilename).collect()) +} + +fn hf_download_by_extension(api: &Api, repo_id: &str, extensions: &[&str]) -> Result { + let files = hf_list_files(api, repo_id)?; + for ext in extensions { + if let Some(fname) = files.iter().find(|f| f.ends_with(ext)) { + return hf_download(api, repo_id, fname); + } + } + bail!( + "No file with extension {:?} found in '{}'.\nAvailable files: {:?}", + extensions, + repo_id, + files + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public constants +// ───────────────────────────────────────────────────────────────────────────── + +pub const CODEC_DECODER_REPO: &str = "neuphonic/neucodec"; +pub const CODEC_SOURCE_FILE: &str = "pytorch_model.bin"; +pub const CODEC_DECODER_FILE: &str = "neucodec_decoder.safetensors"; +pub const CODEC_DECODER_LOCAL: &str = "models/neucodec_decoder.safetensors"; +pub const CODEC_DECODER_SIZE_MB: u32 = 1_100; + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/// Download and load a [`NeuTTS`] model from HuggingFace Hub with progress. +pub fn load_from_hub_cb(backbone_repo: &str, gguf_file: Option<&str>, mut on_progress: F) -> Result +where + F: FnMut(LoadProgress), +{ + let api = Api::new().context("Failed to initialise HuggingFace Hub client")?; + + let backbone_size_mb = find_model(backbone_repo).map(|m| m.size_mb); + let file_label = gguf_file.unwrap_or("*.gguf").to_string(); + on_progress(LoadProgress::Fetching { + step: 1, + total: 3, + file: file_label, + repo: backbone_repo.into(), + size_mb: backbone_size_mb, + }); + + let resolved_gguf: String = match gguf_file { + Some(fname) => fname.to_string(), + None => { + let files = hf_list_files(&api, backbone_repo) + .with_context(|| format!("Failed to list files in '{backbone_repo}'"))?; + files + .into_iter() + .find(|f| f.ends_with(".gguf")) + .with_context(|| format!("No .gguf file found in '{backbone_repo}'"))? + } + }; + let backbone_path = hf_download_cb(&api, backbone_repo, &resolved_gguf, |dl, tot| { + on_progress(LoadProgress::Downloading { + step: 1, + total: 3, + downloaded: dl, + total_bytes: tot, + }); + }) + .with_context(|| format!("Failed to download '{resolved_gguf}' from '{backbone_repo}'"))?; + + let local_decoder = std::path::Path::new(CODEC_DECODER_LOCAL); + let decoder_path: PathBuf = if local_decoder.exists() { + on_progress(LoadProgress::Fetching { + step: 2, + total: 3, + file: CODEC_DECODER_FILE.into(), + repo: "(local cache)".into(), + size_mb: None, + }); + local_decoder.to_path_buf() + } else { + on_progress(LoadProgress::Fetching { + step: 2, + total: 3, + file: CODEC_SOURCE_FILE.into(), + repo: CODEC_DECODER_REPO.into(), + size_mb: Some(CODEC_DECODER_SIZE_MB), + }); + let bin_path = hf_download_cb(&api, CODEC_DECODER_REPO, CODEC_SOURCE_FILE, |dl, tot| { + on_progress(LoadProgress::Downloading { + step: 2, + total: 3, + downloaded: dl, + total_bytes: tot, + }); + }) + .with_context(|| format!("Failed to download '{CODEC_SOURCE_FILE}' from '{CODEC_DECODER_REPO}'"))?; + on_progress(LoadProgress::Loading { + step: 2, + total: 3, + component: format!("converting {CODEC_SOURCE_FILE} → {CODEC_DECODER_FILE}"), + }); + convert_checkpoint(&bin_path, local_decoder).context("Failed to convert NeuCodec checkpoint to safetensors")?; + local_decoder.to_path_buf() + }; + + on_progress(LoadProgress::Loading { + step: 3, + total: 3, + component: "backbone + NeuCodec decoder".into(), + }); + let language = backbone_language(backbone_repo).to_string(); + NeuTTS::load_with_decoder(&backbone_path, &decoder_path, &language) +} + +/// Download and load a [`NeuTTS`] model from HuggingFace Hub (no progress). +pub fn load_from_hub(backbone_repo: &str) -> Result { + load_from_hub_cb(backbone_repo, None, |_| {}) +} + +/// Load the default NeuTTS-Nano Q4 model. +pub fn load_default() -> Result { + load_from_hub("neuphonic/neutts-nano-q4-gguf") +} + +pub fn list_gguf_files(backbone_repo: &str) -> Result> { + let api = Api::new().context("Failed to initialise HuggingFace Hub client")?; + let files = hf_list_files(&api, backbone_repo)?; + Ok(files.into_iter().filter(|f| f.ends_with(".gguf")).collect()) +} + +pub fn supported_backbone_repos() -> Vec<&'static str> { + BACKBONE_MODELS.iter().map(|m| m.repo).collect() +} + +// ───────────────────────────────────────────────────────────────────────────── +// Checkpoint conversion (pytorch_model.bin → safetensors, no PyTorch needed) +// ───────────────────────────────────────────────────────────────────────────── + +/// Convert a `pytorch_model.bin` ZIP archive to a safetensors file (pure Rust). +pub fn convert_neucodec_checkpoint( + bin_path: &std::path::Path, + out_path: &std::path::Path, + n_heads: u32, + repo: &str, +) -> Result<()> { + convert_checkpoint_inner(bin_path, out_path, n_heads, repo) +} + +fn convert_checkpoint(bin_path: &std::path::Path, out_path: &std::path::Path) -> Result<()> { + convert_checkpoint_inner(bin_path, out_path, 16, CODEC_DECODER_REPO) +} + +fn convert_checkpoint_inner( + bin_path: &std::path::Path, + out_path: &std::path::Path, + n_heads: u32, + repo: &str, +) -> Result<()> { + use safetensors::tensor::TensorView; + use std::io::Read; + use zip::ZipArchive; + + println!( + "[neutts] Converting {} → {} (this runs once) …", + bin_path.display(), + out_path.display() + ); + + let file = std::fs::File::open(bin_path).with_context(|| format!("Cannot open {}", bin_path.display()))?; + let mut zip = ZipArchive::new(file).context("Not a valid PyTorch ZIP archive")?; + + let prefix = { + let first = zip.by_index(0).context("Empty ZIP archive")?; + first.name().split('/').next().unwrap_or("archive").to_string() + }; + + let pkl_bytes = { + let mut pkl = zip + .by_name(&format!("{prefix}/data.pkl")) + .with_context(|| format!("data.pkl not found in archive (prefix='{prefix}')"))?; + let mut buf = Vec::new(); + pkl.read_to_end(&mut buf)?; + buf + }; + + let tensors = parse_pickle_metadata(&pkl_bytes).context("Failed to parse pickle tensor metadata")?; + + println!( + "[neutts] Checkpoint: {} tensors; extracting decoder subset …", + tensors.len() + ); + + let decoder_prefixes = ["generator.", "fc_post_a."]; + let mut st_map: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut shapes_map: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + + for (name, meta) in &tensors { + if !decoder_prefixes.iter().any(|p| name.starts_with(p)) { + continue; + } + let data_path = format!("{prefix}/data/{}", meta.storage_key); + let raw_bytes = { + let mut entry = zip + .by_name(&data_path) + .with_context(|| format!("Storage file '{data_path}' not in archive"))?; + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + buf + }; + + let f32_bytes = if meta.is_bf16 { + raw_bytes + .chunks_exact(2) + .map(|b| { + let bits = u16::from_le_bytes([b[0], b[1]]); + f32::from_bits((bits as u32) << 16) + }) + .flat_map(|v| v.to_le_bytes()) + .collect::>() + } else { + let elem_bytes = 4usize; + let start = meta.storage_offset * elem_bytes; + let numel: usize = meta.shape.iter().product(); + let end = start + numel * elem_bytes; + raw_bytes[start..end.min(raw_bytes.len())].to_vec() + }; + + shapes_map.insert(name.clone(), meta.shape.clone()); + st_map.insert(name.clone(), f32_bytes); + } + + if st_map.is_empty() { + bail!("No decoder tensors found in checkpoint — unexpected checkpoint structure"); + } + println!("[neutts] Extracted {} decoder tensors", st_map.len()); + + let hidden_dim = shapes_map + .get("generator.backbone.embed.weight") + .map(|s| s[0]) + .unwrap_or(1024); + let out_dim = shapes_map + .get("generator.head.out.weight") + .map(|s| s[0]) + .unwrap_or(1922); + let hop_length = (out_dim - 2) / 4; + let depth = tensors + .keys() + .filter(|k| k.starts_with("generator.backbone.transformers.") && k.ends_with(".att_norm.weight")) + .count(); + + let mut views: Vec<(&str, TensorView<'_>)> = Vec::new(); + let entries: Vec<(String, Vec)> = st_map.into_iter().collect(); + for (name, bytes) in &entries { + let shape = shapes_map[name].clone(); + let view = TensorView::new(safetensors::tensor::Dtype::F32, shape, bytes) + .with_context(|| format!("TensorView failed for '{name}'"))?; + views.push((name.as_str(), view)); + } + + let mut metadata = std::collections::HashMap::new(); + metadata.insert("hidden_dim".to_string(), hidden_dim.to_string()); + metadata.insert("depth".to_string(), depth.to_string()); + metadata.insert("n_heads".to_string(), n_heads.to_string()); + metadata.insert("hop_length".to_string(), hop_length.to_string()); + metadata.insert("source".to_string(), repo.to_string()); + + std::fs::create_dir_all(out_path.parent().unwrap_or(std::path::Path::new("."))) + .context("Cannot create models/ directory")?; + safetensors::serialize_to_file(views.iter().map(|(n, v)| (*n, v)), &Some(metadata), out_path) + .with_context(|| format!("Failed to write {}", out_path.display()))?; + + let size_mb = std::fs::metadata(out_path)?.len() / 1_048_576; + println!("[neutts] Saved {} MB → {}", size_mb, out_path.display()); + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Minimal pickle parser (reconstructs tensor metadata only) +// ───────────────────────────────────────────────────────────────────────────── + +struct TensorMeta { + storage_key: String, + storage_offset: usize, + shape: Vec, + is_bf16: bool, +} + +impl Clone for TensorMeta { + fn clone(&self) -> Self { + TensorMeta { + storage_key: self.storage_key.clone(), + storage_offset: self.storage_offset, + shape: self.shape.clone(), + is_bf16: self.is_bf16, + } + } +} + +fn parse_pickle_metadata(pkl: &[u8]) -> Result> { + use std::collections::BTreeMap; + + const MARK: u8 = b'('; + const STOP: u8 = b'.'; + const POP: u8 = b'0'; + const POP_MARK: u8 = b'1'; + const DUP: u8 = b'2'; + const FLOAT: u8 = b'F'; + const INT: u8 = b'I'; + const LONG: u8 = b'L'; + const NONE: u8 = b'N'; + const REDUCE: u8 = b'R'; + const STRING: u8 = b'S'; + const UNICODE: u8 = b'V'; + const APPEND: u8 = b'a'; + const BUILD: u8 = b'b'; + const GLOBAL: u8 = b'c'; + const DICT: u8 = b'd'; + const EMPTY_DICT: u8 = b'}'; + const APPENDS: u8 = b'e'; + const GET: u8 = b'g'; + const BINGET: u8 = b'h'; + const LONG_BINGET: u8 = b'j'; + const INST: u8 = b'i'; + const LIST: u8 = b'l'; + const EMPTY_LIST: u8 = b']'; + const OBJ: u8 = b'o'; + const PUT: u8 = b'p'; + const BINPUT: u8 = b'q'; + const LONG_BINPUT: u8 = b'r'; + const SETITEM: u8 = b's'; + const TUPLE: u8 = b't'; + const SETITEMS: u8 = b'u'; + const EMPTY_TUPLE: u8 = b')'; + const PROTO: u8 = 0x80; + const NEWOBJ: u8 = 0x81; + const TUPLE1: u8 = 0x85; + const TUPLE2: u8 = 0x86; + const TUPLE3: u8 = 0x87; + const NEWTRUE: u8 = 0x88; + const NEWFALSE: u8 = 0x89; + const SHORT_BINUNICODE: u8 = 0x8c; + const BININT1: u8 = b'K'; + const BININT2: u8 = b'M'; + const BININT: u8 = b'J'; + const LONG1: u8 = 0x8a; + const LONG4: u8 = 0x8b; + const BINUNICODE: u8 = b'X'; + const EMPTY_SET: u8 = 0x8f; + const FROZENSET: u8 = 0x91; + const NEWOBJ_EX: u8 = 0x92; + const STACK_GLOBAL: u8 = 0x93; + const MEMOIZE: u8 = 0x94; + const FRAME: u8 = 0x95; + + #[derive(Clone, Debug)] + #[allow(dead_code)] + enum Val { + None, + Bool(bool), + Int(i64), + Float(f64), + Str(String), + List(Vec), + Tuple(Vec), + Dict(Vec<(Val, Val)>), + Global(String, String), + Storage(String, bool), + Tensor(TensorMetaInner), + Opaque, + } + + #[derive(Clone, Debug)] + struct TensorMetaInner { + storage_key: String, + storage_offset: usize, + shape: Vec, + is_bf16: bool, + } + + let mut stack: Vec = Vec::new(); + let mut mark_stack: Vec = Vec::new(); + let mut memo: BTreeMap = BTreeMap::new(); + let mut pos = 0usize; + let mut result: BTreeMap = BTreeMap::new(); + + macro_rules! read_byte { + () => {{ + let b = pkl[pos]; + pos += 1; + b + }}; + } + macro_rules! read_u16 { + () => {{ + let v = u16::from_le_bytes([pkl[pos], pkl[pos + 1]]); + pos += 2; + v + }}; + } + macro_rules! read_i32 { + () => {{ + let v = i32::from_le_bytes(pkl[pos..pos + 4].try_into().unwrap()); + pos += 4; + v + }}; + } + macro_rules! read_u32 { + () => {{ + let v = u32::from_le_bytes(pkl[pos..pos + 4].try_into().unwrap()); + pos += 4; + v + }}; + } + macro_rules! read_u64 { + () => {{ + let v = u64::from_le_bytes(pkl[pos..pos + 8].try_into().unwrap()); + pos += 8; + v + }}; + } + macro_rules! read_line { + () => {{ + let start = pos; + while pos < pkl.len() && pkl[pos] != b'\n' { + pos += 1; + } + let s = std::str::from_utf8(&pkl[start..pos]).unwrap_or("").to_string(); + pos += 1; + s + }}; + } + macro_rules! read_bytes { + ($n:expr) => {{ + let n = $n as usize; + let slice = &pkl[pos..pos + n]; + pos += n; + slice + }}; + } + + fn apply_global(func: Val, args: Val) -> Val { + match (&func, &args) { + (Val::Global(m, n), Val::Tuple(a)) => { + let is_bf16 = n == "BFloat16Storage"; + if m.starts_with("torch") && (n.ends_with("Storage") || n == "storage") { + return Val::Storage(String::new(), is_bf16); + } + if (m == "torch._utils" || m == "torch") && n == "_rebuild_tensor_v2" { + if let (Some(Val::Storage(key, bf16)), Some(Val::Int(off)), Some(Val::Tuple(sz)), _) = + (a.first(), a.get(1), a.get(2), a.get(3)) + { + let shape: Vec = sz + .iter() + .filter_map(|v| if let Val::Int(i) = v { Some(*i as usize) } else { None }) + .collect(); + return Val::Tensor(TensorMetaInner { + storage_key: key.clone(), + storage_offset: *off as usize, + shape, + is_bf16: *bf16, + }); + } + } + if n == "_rebuild_parameter" || n == "_rebuild_parameter_with_state" { + if let Some(t @ Val::Tensor(_)) = a.first() { + return t.clone(); + } + } + Val::Opaque + } + _ => Val::Opaque, + } + } + + while pos < pkl.len() { + let op = read_byte!(); + match op { + PROTO => { + read_byte!(); + } + FRAME => { + read_u64!(); + } + NONE => stack.push(Val::None), + NEWTRUE => stack.push(Val::Bool(true)), + NEWFALSE => stack.push(Val::Bool(false)), + BININT1 => { + let v = read_byte!() as i64; + stack.push(Val::Int(v)); + } + BININT2 => { + let v = read_u16!() as i64; + stack.push(Val::Int(v)); + } + BININT => { + let v = read_i32!() as i64; + stack.push(Val::Int(v)); + } + LONG1 => { + let n = read_byte!() as usize; + let bs = read_bytes!(n); + let mut v = 0i64; + for (i, &b) in bs.iter().enumerate() { + v |= (b as i64) << (8 * i); + } + stack.push(Val::Int(v)); + } + LONG4 => { + let n = read_i32!() as usize; + let bs = read_bytes!(n); + let mut v = 0i64; + for (i, &b) in bs.iter().enumerate() { + v |= (b as i64) << (8 * i); + } + stack.push(Val::Int(v)); + } + INT | LONG => { + let s = read_line!(); + let v: i64 = s.trim_end_matches('L').parse().unwrap_or(0); + stack.push(Val::Int(v)); + } + FLOAT => { + let s = read_line!(); + stack.push(Val::Float(s.parse().unwrap_or(0.0))); + } + BINUNICODE => { + let n = read_u32!() as usize; + let bs = read_bytes!(n); + stack.push(Val::Str(String::from_utf8_lossy(bs).into())); + } + SHORT_BINUNICODE => { + let n = read_byte!() as usize; + let bs = read_bytes!(n); + stack.push(Val::Str(String::from_utf8_lossy(bs).into())); + } + STRING | UNICODE => { + let s = read_line!(); + stack.push(Val::Str(s.trim_matches('\'').to_string())); + } + b'T' => { + let n = read_i32!() as usize; + let bs = read_bytes!(n); + stack.push(Val::Str(String::from_utf8_lossy(bs).into())); + } + b'U' => { + let n = read_byte!() as usize; + let bs = read_bytes!(n); + stack.push(Val::Str(String::from_utf8_lossy(bs).into())); + } + GLOBAL => { + let m = read_line!(); + let n = read_line!(); + stack.push(Val::Global(m, n)); + } + STACK_GLOBAL => { + let name = stack.pop().unwrap_or(Val::None); + let module = stack.pop().unwrap_or(Val::None); + if let (Val::Str(m), Val::Str(n)) = (module, name) { + stack.push(Val::Global(m, n)); + } else { + stack.push(Val::Opaque); + } + } + b'P' => { + let s = read_line!(); + let parts: Vec<&str> = s.split(',').collect(); + let key = parts.get(2).unwrap_or(&"0").to_string(); + let tp = parts.get(1).unwrap_or(&"FloatStorage").to_string(); + let is_bf16 = tp == "BFloat16Storage"; + stack.push(Val::Storage(key, is_bf16)); + } + b'Q' => { + let pid = stack.pop().unwrap_or(Val::None); + let storage = match &pid { + Val::Tuple(parts) => { + let key = parts + .get(2) + .and_then(|v| if let Val::Str(s) = v { Some(s.clone()) } else { None }) + .unwrap_or_default(); + let is_bf16 = parts + .get(1) + .map(|v| { + if let Val::Global(_, n) = v { + n.contains("BFloat16") + } else { + false + } + }) + .unwrap_or(false); + Val::Storage(key, is_bf16) + } + _ => Val::Opaque, + }; + stack.push(storage); + } + EMPTY_TUPLE => stack.push(Val::Tuple(vec![])), + TUPLE1 => { + let a = stack.pop().unwrap_or(Val::None); + stack.push(Val::Tuple(vec![a])); + } + TUPLE2 => { + let b = stack.pop().unwrap_or(Val::None); + let a = stack.pop().unwrap_or(Val::None); + stack.push(Val::Tuple(vec![a, b])); + } + TUPLE3 => { + let c = stack.pop().unwrap_or(Val::None); + let b = stack.pop().unwrap_or(Val::None); + let a = stack.pop().unwrap_or(Val::None); + stack.push(Val::Tuple(vec![a, b, c])); + } + TUPLE => { + let mark = mark_stack.pop().unwrap_or(0); + let items: Vec = stack.drain(mark..).collect(); + stack.push(Val::Tuple(items)); + } + EMPTY_LIST => stack.push(Val::List(vec![])), + LIST => { + let mark = mark_stack.pop().unwrap_or(0); + let items: Vec = stack.drain(mark..).collect(); + stack.push(Val::List(items)); + } + APPEND => { + let v = stack.pop().unwrap_or(Val::None); + if let Some(Val::List(ref mut l)) = stack.last_mut() { + l.push(v); + } + } + APPENDS => { + let mark = mark_stack.pop().unwrap_or(0); + let items: Vec = stack.drain(mark..).collect(); + if let Some(Val::List(ref mut l)) = stack.last_mut() { + l.extend(items); + } + } + EMPTY_DICT | EMPTY_SET => stack.push(Val::Dict(vec![])), + DICT => { + let mark = mark_stack.pop().unwrap_or(0); + let items: Vec = stack.drain(mark..).collect(); + let pairs = items + .chunks(2) + .map(|c| (c[0].clone(), c.get(1).cloned().unwrap_or(Val::None))) + .collect(); + stack.push(Val::Dict(pairs)); + } + SETITEM => { + let v = stack.pop().unwrap_or(Val::None); + let k = stack.pop().unwrap_or(Val::None); + if let (Val::Str(name), Val::Tensor(meta)) = (&k, &v) { + result.insert( + name.clone(), + TensorMeta { + storage_key: meta.storage_key.clone(), + storage_offset: meta.storage_offset, + shape: meta.shape.clone(), + is_bf16: meta.is_bf16, + }, + ); + } + if let Some(Val::Dict(ref mut d)) = stack.last_mut() { + d.push((k, v)); + } + } + SETITEMS => { + let mark = mark_stack.pop().unwrap_or(0); + let items: Vec = stack.drain(mark..).collect(); + for chunk in items.chunks(2) { + let k = chunk[0].clone(); + let v = chunk.get(1).cloned().unwrap_or(Val::None); + if let (Val::Str(name), Val::Tensor(meta)) = (&k, &v) { + result.insert( + name.clone(), + TensorMeta { + storage_key: meta.storage_key.clone(), + storage_offset: meta.storage_offset, + shape: meta.shape.clone(), + is_bf16: meta.is_bf16, + }, + ); + } + if let Some(Val::Dict(ref mut d)) = stack.last_mut() { + d.push((k, v)); + } + } + } + REDUCE => { + let args = stack.pop().unwrap_or(Val::None); + let func = stack.pop().unwrap_or(Val::None); + stack.push(apply_global(func, args)); + } + NEWOBJ | NEWOBJ_EX => { + let args = stack.pop().unwrap_or(Val::None); + let cls = stack.pop().unwrap_or(Val::None); + stack.push(apply_global(cls, args)); + } + BUILD => { + let _state = stack.pop(); + } + INST | OBJ => { + let mark = mark_stack.pop().unwrap_or(0); + let _items: Vec = stack.drain(mark..).collect(); + stack.push(Val::Opaque); + } + MEMOIZE => { + let key = memo.len() as u64; + if let Some(v) = stack.last() { + memo.insert(key, v.clone()); + } + } + PUT => { + let _k = read_line!(); + } + BINPUT => { + let k = read_byte!() as u64; + if let Some(v) = stack.last() { + memo.insert(k, v.clone()); + } + } + LONG_BINPUT => { + let k = read_u32!() as u64; + if let Some(v) = stack.last() { + memo.insert(k, v.clone()); + } + } + GET => { + let k: u64 = read_line!().parse().unwrap_or(0); + stack.push(memo.get(&k).cloned().unwrap_or(Val::None)); + } + BINGET => { + let k = read_byte!() as u64; + stack.push(memo.get(&k).cloned().unwrap_or(Val::None)); + } + LONG_BINGET => { + let k = read_u32!() as u64; + stack.push(memo.get(&k).cloned().unwrap_or(Val::None)); + } + MARK => mark_stack.push(stack.len()), + POP => { + stack.pop(); + } + POP_MARK => { + let mark = mark_stack.pop().unwrap_or(0); + stack.truncate(mark); + } + DUP => { + if let Some(v) = stack.last() { + stack.push(v.clone()); + } + } + STOP => break, + FROZENSET => stack.push(Val::Dict(vec![])), + _ => {} + } + } + + fn scan_val(val: &Val, out: &mut BTreeMap) { + match val { + Val::Dict(pairs) => { + for (k, v) in pairs { + if let (Val::Str(name), Val::Tensor(meta)) = (k, v) { + out.entry(name.clone()).or_insert_with(|| TensorMeta { + storage_key: meta.storage_key.clone(), + storage_offset: meta.storage_offset, + shape: meta.shape.clone(), + is_bf16: meta.is_bf16, + }); + } + scan_val(v, out); + } + } + Val::List(items) | Val::Tuple(items) => { + for item in items { + scan_val(item, out); + } + } + _ => {} + } + } + for v in &stack { + scan_val(v, &mut result); + } + + Ok(result) +} diff --git a/crates/skill-neutts/src/lib.rs b/crates/skill-neutts/src/lib.rs new file mode 100644 index 00000000..176503e9 --- /dev/null +++ b/crates/skill-neutts/src/lib.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! Workspace wrapper around [`rlx_neutts`] — adds NPY I/O, reference-code cache, +//! HuggingFace download helpers, espeak phonemisation, and text preprocessing +//! that `rlx_neutts 0.2.0` does not yet ship. + +#[cfg(not(any(target_os = "ios", target_os = "android")))] +pub mod download; + +pub mod cache; +pub mod model; +pub mod npy; +pub mod phonemize; +pub mod preprocess; + +/// Re-exports of codec types under the `neutts::codec` namespace that +/// `skill-tts` expects (mirrors the old `neutts` crate module layout). +pub mod codec { + pub use rlx_neutts::{ + NeuCodecDecoder, NeuCodecEncoder, ENCODER_DEFAULT_INPUT_SAMPLES, ENCODER_SAMPLES_PER_TOKEN, + ENCODER_SAMPLE_RATE, SAMPLES_PER_TOKEN, SAMPLE_RATE, + }; +} + +pub use cache::{CacheOutcome, RefCodeCache}; +pub use model::NeuTTS; + +// Root-level re-exports for convenience. +pub use rlx_neutts::{GenerationConfig, NeuCodecDecoder, NeuCodecEncoder, SAMPLE_RATE}; diff --git a/crates/skill-neutts/src/model.rs b/crates/skill-neutts/src/model.rs new file mode 100644 index 00000000..d828f053 --- /dev/null +++ b/crates/skill-neutts/src/model.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! NeuTTS model wrapper — GGUF backbone + NeuCodec decoder, voice-cloning. +//! +//! Wraps [`rlx_neutts::NeuTTS`] and adds the utility layer that +//! `rlx_neutts 0.2.0` does not yet include: NPY reference-code I/O, +//! WAV writing, and plain-text inference via espeak phonemisation. + +use std::path::Path; + +use anyhow::{Context, Result}; +use rlx_neutts::{GenerationConfig, NeuCodecDecoder, NeuCodecEncoder, NeuTTS as RlxNeuTTS, SAMPLE_RATE}; + +use crate::npy; + +pub struct NeuTTS { + pub(crate) inner: RlxNeuTTS, +} + +impl NeuTTS { + pub fn load_with_decoder(backbone_path: &Path, decoder_path: &Path, language: &str) -> Result { + Ok(Self { + inner: RlxNeuTTS::load_with_decoder(backbone_path, decoder_path, language)?, + }) + } + + pub fn load(backbone_path: &Path, language: &str) -> Result { + Ok(Self { + inner: RlxNeuTTS::load(backbone_path, language)?, + }) + } + + pub fn codec(&self) -> &NeuCodecDecoder { + &self.inner.codec + } + + pub fn language(&self) -> &str { + &self.inner.language + } + + pub fn config(&self) -> &GenerationConfig { + &self.inner.config + } + + pub fn set_config(&mut self, config: GenerationConfig) { + self.inner.config = config; + } + + pub fn load_ref_codes(&self, path: &Path) -> Result> { + npy::load_npy_i32(path).with_context(|| format!("Failed to load reference codes: {}", path.display())) + } + + pub fn load_ref_codes_from_bytes(&self, bytes: &[u8]) -> Result> { + npy::parse_npy(bytes) + .context("Failed to parse embedded NPY reference codes")? + .into_i32() + .context("Failed to convert embedded NPY to i32") + } + + pub fn encode_reference(&self, wav_path: &Path, encoder: &NeuCodecEncoder) -> Result> { + encoder + .encode_wav(wav_path) + .with_context(|| format!("Failed to encode reference audio: {}", wav_path.display())) + } + + pub fn save_ref_codes(&self, codes: &[i32], path: &Path) -> Result<()> { + npy::write_npy_i32(path, codes).with_context(|| format!("Failed to save reference codes: {}", path.display())) + } + + /// Synthesise speech from plain text using espeak-ng phonemisation. + /// + /// Requires the `espeak` Cargo feature. Returns an error when the feature + /// is disabled — use [`infer_from_ipa`](Self::infer_from_ipa) to bypass + /// phonemisation. + pub fn infer(&self, text: &str, ref_codes: &[i32], ref_text: &str) -> Result> { + #[cfg(feature = "espeak")] + { + let ref_phones = + crate::phonemize::phonemize(ref_text, self.language()).context("Phonemisation of ref_text failed")?; + let input_phones = + crate::phonemize::phonemize(text, self.language()).context("Phonemisation of input text failed")?; + return self.infer_from_ipa(&input_phones, ref_codes, &ref_phones); + } + #[cfg(not(feature = "espeak"))] + { + let _ = (text, ref_codes, ref_text); + anyhow::bail!( + "NeuTTS::infer requires the `espeak` Cargo feature.\n\ + Enable it or use NeuTTS::infer_from_ipa() to bypass phonemisation." + ) + } + } + + pub fn infer_from_ipa(&self, input_ipa: &str, ref_codes: &[i32], ref_ipa: &str) -> Result> { + self.inner.infer_from_ipa(input_ipa, ref_codes, ref_ipa) + } + + pub fn decode_tokens(&self, speech_ids: &[i32]) -> Result> { + self.inner.decode_tokens(speech_ids) + } + + /// Write `audio` (f32 PCM, 24 kHz mono) to a 16-bit WAV file. + pub fn write_wav(&self, audio: &[f32], output_path: &Path) -> Result<()> { + let peak = audio.iter().map(|&s| s.abs()).fold(0.0f32, f32::max); + let scale = if peak > 1.0 { 1.0 / peak } else { 1.0 }; + + let spec = hound::WavSpec { + channels: 1, + sample_rate: SAMPLE_RATE, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + let mut writer = hound::WavWriter::create(output_path, spec) + .with_context(|| format!("Cannot create WAV: {}", output_path.display()))?; + for &s in audio { + let s16 = (s * scale * i16::MAX as f32).clamp(i16::MIN as f32, i16::MAX as f32) as i16; + writer.write_sample(s16).context("WAV write error")?; + } + writer.finalize().context("WAV finalise error") + } + + /// Encode `audio` to in-memory 16-bit PCM WAV bytes. + pub fn to_wav_bytes(&self, audio: &[f32]) -> Vec { + let peak = audio.iter().map(|&s| s.abs()).fold(0.0f32, f32::max); + let scale = if peak > 1.0 { 1.0 / peak } else { 1.0 }; + + let num_channels: u16 = 1; + let bits_per_sample: u16 = 16; + let sample_rate: u32 = SAMPLE_RATE; + let byte_rate: u32 = sample_rate * num_channels as u32 * bits_per_sample as u32 / 8; + let block_align: u16 = num_channels * bits_per_sample / 8; + let data_size: u32 = (audio.len() * 2) as u32; + + let mut buf = Vec::with_capacity(44 + audio.len() * 2); + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&(36 + data_size).to_le_bytes()); + buf.extend_from_slice(b"WAVE"); + buf.extend_from_slice(b"fmt "); + buf.extend_from_slice(&16u32.to_le_bytes()); + buf.extend_from_slice(&1u16.to_le_bytes()); + buf.extend_from_slice(&num_channels.to_le_bytes()); + buf.extend_from_slice(&sample_rate.to_le_bytes()); + buf.extend_from_slice(&byte_rate.to_le_bytes()); + buf.extend_from_slice(&block_align.to_le_bytes()); + buf.extend_from_slice(&bits_per_sample.to_le_bytes()); + buf.extend_from_slice(b"data"); + buf.extend_from_slice(&data_size.to_le_bytes()); + for &s in audio { + let s16 = (s * scale * i16::MAX as f32).clamp(i16::MIN as f32, i16::MAX as f32) as i16; + buf.extend_from_slice(&s16.to_le_bytes()); + } + buf + } +} diff --git a/crates/skill-neutts/src/npy.rs b/crates/skill-neutts/src/npy.rs new file mode 100644 index 00000000..09eac657 --- /dev/null +++ b/crates/skill-neutts/src/npy.rs @@ -0,0 +1,282 @@ +//! Minimal NPY / NPZ reader — supports `float32` and `int32` dtypes. +//! +//! Used to load pre-encoded NeuCodec reference codes (int32 1-D arrays) +//! and, optionally, float32 data from NPZ archives. + +use anyhow::{bail, Context, Result}; +use std::{collections::HashMap, io::Read, path::Path}; +use zip::ZipArchive; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Dtype { + Float32, + Int32, +} + +impl Dtype { + fn from_descr(s: &str) -> Result<(Self, bool)> { + let s = s.trim().trim_matches('\'').trim_matches('"'); + let big_endian = s.starts_with('>'); + let dtype = match s { + "f4" => Dtype::Float32, + "i4" => Dtype::Int32, + "u4" => Dtype::Int32, + other => bail!("Unsupported dtype '{}' — only float32 / int32 are supported", other), + }; + Ok((dtype, big_endian)) + } + + fn bytes(self) -> usize { + 4 + } +} + +fn extract_header_field<'a>(header: &'a str, field: &str) -> Option<&'a str> { + let key_sq = format!("'{}':", field); + let key_dq = format!("\"{}\":", field); + let start = header + .find(key_sq.as_str()) + .map(|p| p + key_sq.len()) + .or_else(|| header.find(key_dq.as_str()).map(|p| p + key_dq.len()))?; + let rest = header[start..].trim_start(); + if rest.starts_with('(') { + let end = rest.find(')')?; + Some(&rest[..end + 1]) + } else if rest.starts_with('\'') || rest.starts_with('"') { + let quote = rest.chars().next()?; + let inner = &rest[1..]; + let end = inner.find(quote)?; + Some(&inner[..end]) + } else { + let end = rest.find([',', '}']).unwrap_or(rest.len()); + Some(rest[..end].trim()) + } +} + +fn parse_shape(s: &str) -> Result> { + let inner = s.trim_start_matches('(').trim_end_matches(')'); + if inner.trim().is_empty() { + return Ok(vec![]); + } + inner + .split(',') + .map(|t| t.trim()) + .filter(|t| !t.is_empty()) + .map(|t| t.parse::().with_context(|| format!("Bad shape dim: '{t}'"))) + .collect() +} + +/// A loaded NPY array. +pub enum NpyData { + Float32 { shape: Vec, data: Vec }, + Int32 { shape: Vec, data: Vec }, +} + +impl NpyData { + pub fn len(&self) -> usize { + match self { + Self::Float32 { data, .. } => data.len(), + Self::Int32 { data, .. } => data.len(), + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn shape(&self) -> &[usize] { + match self { + Self::Float32 { shape, .. } => shape, + Self::Int32 { shape, .. } => shape, + } + } + + pub fn into_i32(self) -> Result> { + match self { + Self::Int32 { data, .. } => Ok(data), + Self::Float32 { data, .. } => Ok(data.into_iter().map(|f| f as i32).collect()), + } + } + + pub fn into_f32(self) -> Result> { + match self { + Self::Float32 { data, .. } => Ok(data), + Self::Int32 { data, .. } => Ok(data.into_iter().map(|i| i as f32).collect()), + } + } +} + +/// Parse a raw `.npy` byte buffer into an [`NpyData`]. +pub fn parse_npy(raw: &[u8]) -> Result { + if raw.len() < 10 || &raw[..6] != b"\x93NUMPY" { + bail!("Not a valid NPY file (bad magic)"); + } + let major = raw[6]; + let minor = raw[7]; + let (header_len, header_start) = match (major, minor) { + (1, _) => (u16::from_le_bytes([raw[8], raw[9]]) as usize, 10), + (2, _) => { + if raw.len() < 12 { + bail!("NPY v2 file too short"); + } + (u32::from_le_bytes([raw[8], raw[9], raw[10], raw[11]]) as usize, 12) + } + _ => bail!("Unsupported NPY version {}.{}", major, minor), + }; + let header_end = header_start + header_len; + if raw.len() < header_end { + bail!("NPY file truncated in header"); + } + let header = std::str::from_utf8(&raw[header_start..header_end]).context("NPY header is not valid UTF-8")?; + + let descr = extract_header_field(header, "descr").context("NPY header missing 'descr'")?; + let (dtype, big_endian) = Dtype::from_descr(descr)?; + + let fortran = extract_header_field(header, "fortran_order") + .unwrap_or("False") + .trim() + .to_ascii_lowercase(); + if fortran == "true" { + bail!("Fortran-order arrays are not supported"); + } + + let shape_str = extract_header_field(header, "shape").context("NPY header missing 'shape'")?; + let shape = parse_shape(shape_str.trim())?; + let n: usize = shape.iter().product(); + + let data_bytes = &raw[header_end..]; + let byte_size = n * dtype.bytes(); + if data_bytes.len() < byte_size { + bail!( + "NPY data section too short: expected {byte_size} bytes, got {}", + data_bytes.len() + ); + } + + match dtype { + Dtype::Float32 => { + let data: Vec = data_bytes[..byte_size] + .chunks_exact(4) + .map(|b| { + let arr = [b[0], b[1], b[2], b[3]]; + if big_endian { + f32::from_be_bytes(arr) + } else { + f32::from_le_bytes(arr) + } + }) + .collect(); + Ok(NpyData::Float32 { shape, data }) + } + Dtype::Int32 => { + let data: Vec = data_bytes[..byte_size] + .chunks_exact(4) + .map(|b| { + let arr = [b[0], b[1], b[2], b[3]]; + if big_endian { + i32::from_be_bytes(arr) + } else { + i32::from_le_bytes(arr) + } + }) + .collect(); + Ok(NpyData::Int32 { shape, data }) + } + } +} + +/// Load a `.npy` file and return an [`NpyData`]. +pub fn load_npy(path: &Path) -> Result { + let raw = std::fs::read(path).with_context(|| format!("Cannot read NPY file: {}", path.display()))?; + parse_npy(&raw).with_context(|| format!("Failed to parse NPY: {}", path.display())) +} + +/// Load a `.npy` file and return the data as a flat `Vec`. +pub fn load_npy_i32(path: &Path) -> Result> { + load_npy(path)?.into_i32() +} + +/// Write a 1-D `int32` array to a `.npy` file (NPY v1.0, ` Result<()> { + let header_str = format!( + "{{'descr': ' Result> { + let file = std::fs::File::open(path).with_context(|| format!("Cannot open NPZ: {}", path.display()))?; + let mut archive = ZipArchive::new(file).with_context(|| format!("Cannot open ZIP archive: {}", path.display()))?; + let mut arrays = HashMap::new(); + for i in 0..archive.len() { + let mut entry = archive.by_index(i).context("Failed to read ZIP entry")?; + let name = entry.name().trim_end_matches(".npy").to_string(); + let mut buf = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut buf).context("Failed to read NPY entry")?; + let arr = parse_npy(&buf).with_context(|| format!("Failed to parse NPY entry '{name}'"))?; + arrays.insert(name, arr); + } + Ok(arrays) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_npy_i32(values: &[i32]) -> Vec { + let n = values.len(); + let header_str = format!("{{'descr': ' = OnceCell::new(); + +/// Override the espeak-ng data directory (optional with bundled-data feature). +pub fn set_data_path(path: &Path) { + let _ = DATA_PATH.set(path.to_path_buf()); +} + +#[cfg(feature = "espeak")] +mod inner { + use std::path::PathBuf; + + use anyhow::{anyhow, Result}; + use once_cell::sync::OnceCell; + + use super::DATA_PATH; + + static BUNDLED_DATA_DIR: OnceCell = OnceCell::new(); + + fn get_data_dir() -> Result<&'static PathBuf> { + if let Some(user_dir) = DATA_PATH.get() { + return Ok(BUNDLED_DATA_DIR.get_or_init(|| user_dir.clone())); + } + BUNDLED_DATA_DIR.get_or_try_init(|| { + if let Ok(p) = std::env::var("NEUTTS_ESPEAK_DATA_DIR") { + if !p.is_empty() { + let path = PathBuf::from(p); + if path.is_dir() { + return Ok(path); + } + return Err(anyhow!("NEUTTS_ESPEAK_DATA_DIR is not a directory: {}", path.display())); + } + } + let cache_dir = std::env::temp_dir().join("neutts-espeak-ng-data"); + std::fs::create_dir_all(&cache_dir).map_err(|e| anyhow!("Failed to create espeak-ng data dir: {}", e))?; + espeak_ng::install_bundled_data(&cache_dir) + .map_err(|e| anyhow!("Failed to install bundled espeak-ng data: {}", e))?; + Ok(cache_dir) + }) + } + + fn map_lang(lang: &str) -> &str { + match lang { + "en-us" => "en", + "fr-fr" => "fr", + other => other, + } + } + + fn create_engine(lang: &str) -> Result { + let data_dir = get_data_dir()?; + let mapped = map_lang(lang); + espeak_ng::EspeakNg::with_data_dir(mapped, data_dir) + .map_err(|e| anyhow!("espeak-ng init for '{}' failed: {}", lang, e)) + } + + pub(super) fn is_available(lang: &str) -> bool { + create_engine(lang).is_ok() + } + + pub(super) fn run_phonemize(text: &str, lang: &str) -> Result { + if text.is_empty() { + return Ok(String::new()); + } + let engine = create_engine(lang)?; + let ipa = engine + .text_to_phonemes(text) + .map_err(|e| anyhow!("espeak-ng phonemise failed: {}", e))?; + Ok(ipa.trim().to_owned()) + } +} + +/// Returns `true` if espeak-ng is available for the given language code. +pub fn is_espeak_available(lang: &str) -> bool { + #[cfg(feature = "espeak")] + { + inner::is_available(lang) + } + #[cfg(not(feature = "espeak"))] + { + let _ = lang; + false + } +} + +/// Convert `text` to IPA phonemes using the espeak-ng voice for `lang`. +/// +/// **Requires the `espeak` Cargo feature.** +pub fn phonemize(text: &str, lang: &str) -> Result { + #[cfg(feature = "espeak")] + { + let raw = inner::run_phonemize(text, lang)?; + let cleaned = if lang.starts_with("fr") { + raw.replace('-', "") + } else { + raw + }; + let tokens: Vec<&str> = cleaned.split_whitespace().collect(); + Ok(tokens.join(" ")) + } + #[cfg(not(feature = "espeak"))] + { + let _ = (text, lang); + Err(anyhow!( + "phonemize() requires the `espeak` Cargo feature.\n\ + Enable it or use NeuTTS::infer_from_ipa() to bypass phonemisation." + )) + } +} diff --git a/crates/skill-neutts/src/preprocess.rs b/crates/skill-neutts/src/preprocess.rs new file mode 100644 index 00000000..f2c4d955 --- /dev/null +++ b/crates/skill-neutts/src/preprocess.rs @@ -0,0 +1,745 @@ +//! Text preprocessing pipeline — expands numbers, symbols, abbreviations, etc. +//! before phonemisation. + +use fancy_regex::{Captures, Regex}; +use once_cell::sync::Lazy; +use std::borrow::Cow; + +const ONES: &[&str] = &[ + "", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", +]; +const TENS: &[&str] = &[ + "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety", +]; +const SCALE: &[&str] = &["", "thousand", "million", "billion", "trillion"]; + +fn three_digits_to_words(n: u64) -> String { + if n == 0 { + return String::new(); + } + let mut parts = Vec::new(); + let hundreds = n / 100; + let remainder = n % 100; + if hundreds > 0 { + parts.push(format!("{} hundred", ONES[hundreds as usize])); + } + if remainder < 20 { + if remainder > 0 { + parts.push(ONES[remainder as usize].to_string()); + } + } else { + let tens_word = TENS[(remainder / 10) as usize]; + let ones_word = ONES[(remainder % 10) as usize]; + if ones_word.is_empty() { + parts.push(tens_word.to_string()); + } else { + parts.push(format!("{}-{}", tens_word, ones_word)); + } + } + parts.join(" ") +} + +pub fn number_to_words(n: i64) -> String { + if n < 0 { + return format!("negative {}", number_to_words(-n)); + } + let n = n as u64; + if n == 0 { + return "zero".to_string(); + } + if n >= 100 && n <= 9999 && n % 100 == 0 && n % 1000 != 0 { + let hundreds = n / 100; + if hundreds < 20 { + return format!("{} hundred", ONES[hundreds as usize]); + } + } + let mut parts = Vec::new(); + let mut remaining = n; + for &scale in SCALE.iter() { + let chunk = remaining % 1000; + if chunk > 0 { + let chunk_words = three_digits_to_words(chunk); + if scale.is_empty() { + parts.push(chunk_words); + } else { + parts.push(format!("{} {}", chunk_words, scale)); + } + } + remaining /= 1000; + if remaining == 0 { + break; + } + } + parts.reverse(); + parts.join(" ") +} + +pub fn float_to_words(value: &str) -> String { + let negative = value.starts_with('-'); + let value = if negative { &value[1..] } else { value }; + + let digit_words = |c: char| match c { + '0' => "zero", + '1' => "one", + '2' => "two", + '3' => "three", + '4' => "four", + '5' => "five", + '6' => "six", + '7' => "seven", + '8' => "eight", + '9' => "nine", + _ => "", + }; + + let result = if let Some(dot) = value.find('.') { + let int_part = &value[..dot]; + let dec_part = &value[dot + 1..]; + let int_words = if int_part.is_empty() { + "zero".to_string() + } else { + number_to_words(int_part.parse::().unwrap_or(0)) + }; + let dec_words: Vec<&str> = dec_part.chars().map(digit_words).collect(); + format!("{} point {}", int_words, dec_words.join(" ")) + } else { + number_to_words(value.parse::().unwrap_or(0)) + }; + + if negative { + format!("negative {}", result) + } else { + result + } +} + +fn ordinal_suffix(n: i64) -> String { + let word = number_to_words(n); + let exceptions = [ + ("one", "first"), + ("two", "second"), + ("three", "third"), + ("four", "fourth"), + ("five", "fifth"), + ("six", "sixth"), + ("seven", "seventh"), + ("eight", "eighth"), + ("nine", "ninth"), + ("twelve", "twelfth"), + ]; + let (prefix, last, sep) = if let Some(pos) = word.rfind('-') { + (&word[..pos], &word[pos + 1..], "-") + } else if let Some(pos) = word.rfind(' ') { + (&word[..pos], &word[pos + 1..], " ") + } else { + ("", word.as_str(), "") + }; + + let last_ord = exceptions + .iter() + .find(|(base, _)| *base == last) + .map(|(_, ord)| (*ord).to_string()) + .unwrap_or_else(|| { + if last.ends_with('t') { + format!("{}h", last) + } else if let Some(stripped) = last.strip_suffix('e') { + format!("{}th", stripped) + } else if let Some(stripped) = last.strip_suffix('y') { + format!("{}ieth", stripped) + } else { + format!("{}th", last) + } + }); + + if prefix.is_empty() { + last_ord + } else { + format!("{}{}{}", prefix, sep, last_ord) + } +} + +static RE_ORDINAL: Lazy = Lazy::new(|| Regex::new(r"(?i)\b(\d+)(st|nd|rd|th)\b").unwrap()); +static RE_PERCENT: Lazy = Lazy::new(|| Regex::new(r"(-?[\d,]+(?:\.\d+)?)\s*%").unwrap()); +static RE_CURRENCY: Lazy = + Lazy::new(|| Regex::new(r"([$€£¥₹₩₿])\s*([\d,]+(?:\.\d+)?)\s*([KMBT])?(?![a-zA-Z\d])").unwrap()); +static RE_TIME: Lazy = Lazy::new(|| Regex::new(r"(?i)\b(\d{1,2}):(\d{2})(?::\d{2})?\s*(am|pm)?\b").unwrap()); +static RE_RANGE: Lazy = Lazy::new(|| Regex::new(r"(? = + Lazy::new(|| Regex::new(r"\b([a-zA-Z][a-zA-Z0-9]*)-(\d[\d.]*)(?=[^\d.]|$)").unwrap()); +static RE_UNIT: Lazy = Lazy::new(|| { + Regex::new(r"(?i)(\d+(?:\.\d+)?)\s*(km|kg|mg|ml|gb|mb|kb|tb|hz|khz|mhz|ghz|mph|kph|°[cCfF]|[cCfF]°|ms|ns|µs)\b") + .unwrap() +}); +static RE_SCALE: Lazy = + Lazy::new(|| Regex::new(r"(? = + Lazy::new(|| Regex::new(r"(? = Lazy::new(|| Regex::new(r"\b(\d+)\s*/\s*(\d+)\b").unwrap()); +static RE_DECADE: Lazy = Lazy::new(|| Regex::new(r"\b(\d{1,3})0s\b").unwrap()); +static RE_LEAD_DEC: Lazy = Lazy::new(|| Regex::new(r"(? = Lazy::new(|| Regex::new(r"(? = Lazy::new(|| Regex::new(r"(? = Lazy::new(|| Regex::new(r"\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b").unwrap()); +static RE_PHONE_11: Lazy = + Lazy::new(|| Regex::new(r"(? = Lazy::new(|| Regex::new(r"(? = Lazy::new(|| Regex::new(r"(? = Lazy::new(|| Regex::new(r"https?://\S+|www\.\S+").unwrap()); +static RE_EMAIL: Lazy = Lazy::new(|| Regex::new(r"(?i)\b[\w.+-]+@[\w-]+\.[a-z]{2,}\b").unwrap()); +static RE_HTML: Lazy = Lazy::new(|| Regex::new(r"<[^>]+>").unwrap()); +static RE_SPACES: Lazy = Lazy::new(|| Regex::new(r"\s+").unwrap()); +static RE_PUNCT: Lazy = Lazy::new(|| Regex::new(r"[^\w\s]").unwrap()); +static RE_CONTRACTION_CANT: Lazy = Lazy::new(|| Regex::new(r"(?i)\bcan't\b").unwrap()); +static RE_CONTRACTION_WONT: Lazy = Lazy::new(|| Regex::new(r"(?i)\bwon't\b").unwrap()); +static RE_CONTRACTION_SHANT: Lazy = Lazy::new(|| Regex::new(r"(?i)\bshan't\b").unwrap()); +static RE_CONTRACTION_AINT: Lazy = Lazy::new(|| Regex::new(r"(?i)\bain't\b").unwrap()); +static RE_CONTRACTION_LETS: Lazy = Lazy::new(|| Regex::new(r"(?i)\blet's\b").unwrap()); +static RE_CONTRACTION_ITS: Lazy = Lazy::new(|| Regex::new(r"(?i)\bit's\b").unwrap()); +static RE_CONTRACTION_NT: Lazy = Lazy::new(|| Regex::new(r"(?i)\b(\w+)n't\b").unwrap()); +static RE_CONTRACTION_RE: Lazy = Lazy::new(|| Regex::new(r"(?i)\b(\w+)'re\b").unwrap()); +static RE_CONTRACTION_VE: Lazy = Lazy::new(|| Regex::new(r"(?i)\b(\w+)'ve\b").unwrap()); +static RE_CONTRACTION_LL: Lazy = Lazy::new(|| Regex::new(r"(?i)\b(\w+)'ll\b").unwrap()); +static RE_CONTRACTION_D: Lazy = Lazy::new(|| Regex::new(r"(?i)\b(\w+)'d\b").unwrap()); +static RE_CONTRACTION_M: Lazy = Lazy::new(|| Regex::new(r"(?i)\b(\w+)'m\b").unwrap()); + +fn currency_symbol_name(sym: &str) -> &'static str { + match sym { + "$" => "dollar", + "€" => "euro", + "£" => "pound", + "¥" => "yen", + "₹" => "rupee", + "₩" => "won", + "₿" => "bitcoin", + _ => "", + } +} + +fn scale_suffix_word(s: &str) -> &'static str { + match s { + "K" => "thousand", + "M" => "million", + "B" => "billion", + "T" => "trillion", + _ => "", + } +} + +fn digits_to_words(s: &str) -> String { + s.chars() + .map(|c| match c { + '0' => "zero", + '1' => "one", + '2' => "two", + '3' => "three", + '4' => "four", + '5' => "five", + '6' => "six", + '7' => "seven", + '8' => "eight", + '9' => "nine", + _ => "", + }) + .filter(|s| !s.is_empty()) + .collect::>() + .join(" ") +} + +fn unit_expansion(unit: &str) -> &'static str { + match unit.to_lowercase().as_str() { + "km" => "kilometers", + "kg" => "kilograms", + "mg" => "milligrams", + "ml" => "milliliters", + "gb" => "gigabytes", + "mb" => "megabytes", + "kb" => "kilobytes", + "tb" => "terabytes", + "hz" => "hertz", + "khz" => "kilohertz", + "mhz" => "megahertz", + "ghz" => "gigahertz", + "mph" => "miles per hour", + "kph" => "kilometers per hour", + "ms" => "milliseconds", + "ns" => "nanoseconds", + "µs" => "microseconds", + "°c" | "c°" => "degrees Celsius", + "°f" | "f°" => "degrees Fahrenheit", + _ => "", + } +} + +pub fn expand_ordinals(text: &str) -> String { + RE_ORDINAL + .replace_all(text, |caps: &Captures| { + let n: i64 = caps[1].parse().unwrap_or(0); + ordinal_suffix(n) + }) + .into_owned() +} + +pub fn expand_percentages(text: &str) -> String { + RE_PERCENT + .replace_all(text, |caps: &Captures| { + let raw = caps[1].replace(',', ""); + let words = if raw.contains('.') { + float_to_words(&raw) + } else { + number_to_words(raw.parse::().unwrap_or(0)) + }; + format!("{} percent", words) + }) + .into_owned() +} + +pub fn expand_currency(text: &str) -> String { + RE_CURRENCY + .replace_all(text, |caps: &Captures| { + let symbol = &caps[1]; + let raw = caps[2].replace(',', ""); + let scale_suffix = caps.get(3).map(|m| m.as_str()).unwrap_or(""); + let unit = currency_symbol_name(symbol); + + if !scale_suffix.is_empty() { + let scale_word = scale_suffix_word(scale_suffix); + let num = if raw.contains('.') { + float_to_words(&raw) + } else { + number_to_words(raw.parse::().unwrap_or(0)) + }; + return format!("{} {} {}s", num, scale_word, unit); + } + + if raw.contains('.') { + let dot = raw.find('.').unwrap(); + let int_part: i64 = raw[..dot].parse().unwrap_or(0); + let dec_str = &raw[dot + 1..]; + let dec_val: i64 = dec_str[..dec_str.len().min(2)] + .chars() + .chain(std::iter::repeat('0')) + .take(2) + .collect::() + .parse() + .unwrap_or(0); + let int_words = number_to_words(int_part); + let mut result = if unit.is_empty() { + int_words + } else { + format!("{} {}s", int_words, unit) + }; + if dec_val > 0 { + let cents = number_to_words(dec_val); + let plural = if dec_val == 1 { "" } else { "s" }; + result.push_str(&format!(" and {} cent{}", cents, plural)); + } + result + } else { + let val: i64 = raw.parse().unwrap_or(0); + let words = number_to_words(val); + if unit.is_empty() { + words + } else { + let plural = if val == 1 { "" } else { "s" }; + format!("{} {}{}", words, unit, plural) + } + } + }) + .into_owned() +} + +pub fn expand_time(text: &str) -> String { + RE_TIME + .replace_all(text, |caps: &Captures| { + let h: i64 = caps[1].parse().unwrap_or(0); + let mins: i64 = caps[2].parse().unwrap_or(0); + let suffix = caps + .get(3) + .map(|m| format!(" {}", m.as_str().to_lowercase())) + .unwrap_or_default(); + let h_words = number_to_words(h); + if mins == 0 { + if caps.get(3).is_some() { + format!("{}{}", h_words, suffix) + } else { + format!("{} hundred", h_words) + } + } else if mins < 10 { + format!("{} oh {}{}", h_words, number_to_words(mins), suffix) + } else { + format!("{} {}{}", h_words, number_to_words(mins), suffix) + } + }) + .into_owned() +} + +pub fn expand_ranges(text: &str) -> String { + RE_RANGE + .replace_all(text, |caps: &Captures| { + let lo = number_to_words(caps[1].parse().unwrap_or(0)); + let hi = number_to_words(caps[2].parse().unwrap_or(0)); + format!("{} to {}", lo, hi) + }) + .into_owned() +} + +pub fn expand_model_names(text: &str) -> String { + RE_MODEL_VER + .replace_all(text, |caps: &Captures| format!("{} {}", &caps[1], &caps[2])) + .into_owned() +} + +pub fn expand_units(text: &str) -> String { + RE_UNIT + .replace_all(text, |caps: &Captures| { + let raw = &caps[1]; + let unit = &caps[2]; + let expanded = unit_expansion(unit); + let expanded = if expanded.is_empty() { unit } else { expanded }; + let num = if raw.contains('.') { + float_to_words(raw) + } else { + number_to_words(raw.parse::().unwrap_or(0)) + }; + format!("{} {}", num, expanded) + }) + .into_owned() +} + +pub fn expand_scale_suffixes(text: &str) -> String { + RE_SCALE + .replace_all(text, |caps: &Captures| { + let raw = &caps[1]; + let suffix = &caps[2]; + let scale_word = scale_suffix_word(suffix); + let num = if raw.contains('.') { + float_to_words(raw) + } else { + number_to_words(raw.parse::().unwrap_or(0)) + }; + format!("{} {}", num, scale_word) + }) + .into_owned() +} + +pub fn expand_scientific_notation(text: &str) -> String { + RE_SCI + .replace_all(text, |caps: &Captures| { + let coeff = &caps[1]; + let exp: i64 = caps[2].parse().unwrap_or(0); + let coeff_words = if coeff.contains('.') { + float_to_words(coeff) + } else { + number_to_words(coeff.parse::().unwrap_or(0)) + }; + let exp_words = number_to_words(exp.abs()); + let sign = if exp < 0 { "negative " } else { "" }; + format!("{} times ten to the {}{}", coeff_words, sign, exp_words) + }) + .into_owned() +} + +pub fn expand_fractions(text: &str) -> String { + RE_FRACTION + .replace_all(text, |caps: &Captures| { + let num: i64 = caps[1].parse().unwrap_or(0); + let den: i64 = caps[2].parse().unwrap_or(1); + if den == 0 { + return caps[0].to_string(); + } + let num_words = number_to_words(num); + let denom_word = match den { + 2 => (if num == 1 { "half" } else { "halves" }).to_string(), + 4 => (if num == 1 { "quarter" } else { "quarters" }).to_string(), + _ => { + let ord = ordinal_suffix(den); + if num != 1 { + format!("{}s", ord) + } else { + ord + } + } + }; + format!("{} {}", num_words, denom_word) + }) + .into_owned() +} + +pub fn expand_decades(text: &str) -> String { + let decade_map = [ + (0, "hundreds"), + (1, "tens"), + (2, "twenties"), + (3, "thirties"), + (4, "forties"), + (5, "fifties"), + (6, "sixties"), + (7, "seventies"), + (8, "eighties"), + (9, "nineties"), + ]; + RE_DECADE + .replace_all(text, |caps: &Captures| { + let base: i64 = caps[1].parse().unwrap_or(0); + let decade_digit = (base % 10) as usize; + let decade_word = decade_map + .iter() + .find(|(d, _)| *d == decade_digit as i64) + .map(|(_, w)| *w) + .unwrap_or(""); + if base < 10 { + decade_word.to_string() + } else { + let century_part = base / 10; + format!("{} {}", number_to_words(century_part), decade_word) + } + }) + .into_owned() +} + +pub fn expand_ip_addresses(text: &str) -> String { + RE_IP + .replace_all(text, |caps: &Captures| { + let octets: Vec = (1..=4).map(|i| digits_to_words(&caps[i])).collect(); + octets.join(" dot ") + }) + .into_owned() +} + +pub fn expand_phone_numbers(text: &str) -> String { + let join_groups = + |groups: Vec<&str>| -> String { groups.iter().map(|g| digits_to_words(g)).collect::>().join(" ") }; + let text = RE_PHONE_11 + .replace_all(text, |caps: &Captures| { + join_groups(vec![&caps[1], &caps[2], &caps[3], &caps[4]]) + }) + .into_owned(); + let text = RE_PHONE_10 + .replace_all(&text, |caps: &Captures| join_groups(vec![&caps[1], &caps[2], &caps[3]])) + .into_owned(); + RE_PHONE_7 + .replace_all(&text, |caps: &Captures| join_groups(vec![&caps[1], &caps[2]])) + .into_owned() +} + +pub fn normalize_leading_decimals(text: &str) -> String { + let text = RE_NEG_LEAD_DEC + .replace_all(text, |caps: &Captures| format!("{}0.{}", &caps[1], &caps[2])) + .into_owned(); + RE_LEAD_DEC + .replace_all(&text, |caps: &Captures| format!("0.{}", &caps[1])) + .into_owned() +} + +pub fn replace_numbers(text: &str) -> String { + RE_NUMBER + .replace_all(text, |caps: &Captures| { + let raw = caps[0].replace(',', ""); + if raw.contains('.') { + float_to_words(&raw) + } else if let Ok(n) = raw.parse::() { + number_to_words(n) + } else { + caps[0].to_string() + } + }) + .into_owned() +} + +pub fn expand_contractions(text: &str) -> String { + let text = RE_CONTRACTION_CANT.replace_all(text, "cannot").into_owned(); + let text = RE_CONTRACTION_WONT.replace_all(&text, "will not").into_owned(); + let text = RE_CONTRACTION_SHANT.replace_all(&text, "shall not").into_owned(); + let text = RE_CONTRACTION_AINT.replace_all(&text, "is not").into_owned(); + let text = RE_CONTRACTION_LETS.replace_all(&text, "let us").into_owned(); + let text = RE_CONTRACTION_ITS.replace_all(&text, "it is").into_owned(); + let text = RE_CONTRACTION_NT.replace_all(&text, "$1 not").into_owned(); + let text = RE_CONTRACTION_RE.replace_all(&text, "$1 are").into_owned(); + let text = RE_CONTRACTION_VE.replace_all(&text, "$1 have").into_owned(); + let text = RE_CONTRACTION_LL.replace_all(&text, "$1 will").into_owned(); + let text = RE_CONTRACTION_D.replace_all(&text, "$1 would").into_owned(); + RE_CONTRACTION_M.replace_all(&text, "$1 am").into_owned() +} + +pub fn remove_urls(text: &str) -> Cow<'_, str> { + RE_URL.replace_all(text, "") +} + +pub fn remove_emails(text: &str) -> Cow<'_, str> { + RE_EMAIL.replace_all(text, "") +} + +pub fn remove_html_tags(text: &str) -> Cow<'_, str> { + RE_HTML.replace_all(text, " ") +} + +pub fn remove_punctuation(text: &str) -> Cow<'_, str> { + RE_PUNCT.replace_all(text, " ") +} + +pub fn remove_extra_whitespace(text: &str) -> String { + RE_SPACES.replace_all(text.trim(), " ").into_owned() +} + +#[derive(Debug, Clone)] +pub struct PreprocessorConfig { + pub lowercase: bool, + pub replace_numbers: bool, + pub expand_contractions: bool, + pub expand_model_names: bool, + pub expand_ordinals: bool, + pub expand_percentages: bool, + pub expand_currency: bool, + pub expand_time: bool, + pub expand_ranges: bool, + pub expand_units: bool, + pub expand_scale_suffixes: bool, + pub expand_scientific_notation: bool, + pub expand_fractions: bool, + pub expand_decades: bool, + pub expand_phone_numbers: bool, + pub expand_ip_addresses: bool, + pub normalize_leading_decimals: bool, + pub remove_urls: bool, + pub remove_emails: bool, + pub remove_html: bool, + pub remove_punctuation: bool, + pub remove_extra_whitespace: bool, +} + +impl Default for PreprocessorConfig { + fn default() -> Self { + Self { + lowercase: true, + replace_numbers: true, + expand_contractions: true, + expand_model_names: true, + expand_ordinals: true, + expand_percentages: true, + expand_currency: true, + expand_time: true, + expand_ranges: true, + expand_units: true, + expand_scale_suffixes: true, + expand_scientific_notation: true, + expand_fractions: true, + expand_decades: true, + expand_phone_numbers: true, + expand_ip_addresses: true, + normalize_leading_decimals: true, + remove_urls: true, + remove_emails: true, + remove_html: true, + remove_punctuation: true, + remove_extra_whitespace: true, + } + } +} + +#[derive(Default)] +pub struct TextPreprocessor { + pub config: PreprocessorConfig, +} + +impl TextPreprocessor { + pub fn new() -> Self { + Self::default() + } + + pub fn with_config(config: PreprocessorConfig) -> Self { + Self { config } + } + + pub fn process(&self, text: &str) -> String { + let cfg = &self.config; + let mut text = text.to_string(); + + if cfg.remove_html { + text = remove_html_tags(&text).into_owned(); + } + if cfg.remove_urls { + text = remove_urls(&text).into_owned(); + } + if cfg.remove_emails { + text = remove_emails(&text).into_owned(); + } + if cfg.expand_contractions { + text = expand_contractions(&text); + } + if cfg.expand_ip_addresses { + text = expand_ip_addresses(&text); + } + if cfg.normalize_leading_decimals { + text = normalize_leading_decimals(&text); + } + if cfg.expand_currency { + text = expand_currency(&text); + } + if cfg.expand_percentages { + text = expand_percentages(&text); + } + if cfg.expand_scientific_notation { + text = expand_scientific_notation(&text); + } + if cfg.expand_time { + text = expand_time(&text); + } + if cfg.expand_ordinals { + text = expand_ordinals(&text); + } + if cfg.expand_units { + text = expand_units(&text); + } + if cfg.expand_scale_suffixes { + text = expand_scale_suffixes(&text); + } + if cfg.expand_fractions { + text = expand_fractions(&text); + } + if cfg.expand_decades { + text = expand_decades(&text); + } + if cfg.expand_phone_numbers { + text = expand_phone_numbers(&text); + } + if cfg.expand_ranges { + text = expand_ranges(&text); + } + if cfg.expand_model_names { + text = expand_model_names(&text); + } + if cfg.replace_numbers { + text = replace_numbers(&text); + } + if cfg.remove_punctuation { + text = remove_punctuation(&text).into_owned(); + } + if cfg.lowercase { + text = text.to_lowercase(); + } + if cfg.remove_extra_whitespace { + text = remove_extra_whitespace(&text); + } + + text + } +} diff --git a/crates/skill-router/Cargo.toml b/crates/skill-router/Cargo.toml index 935fd1af..de0740f9 100644 --- a/crates/skill-router/Cargo.toml +++ b/crates/skill-router/Cargo.toml @@ -4,12 +4,33 @@ version = "0.0.1" edition = "2021" license = "GPL-3.0-only" description = "WebSocket/HTTP command routing, UMAP projection, embedding loaders, EEG metric rounding — extracted workspace crate" +build = "build.rs" [features] default = ["gpu"] -gpu = ["dep:burn-cubecl", "dep:cubecl", "fast-umap/gpu"] -mlx = ["dep:burn-mlx", "fast-umap/mlx"] -cpu = ["burn/ndarray"] +gpu = [ + "rlx-umap/gpu", + "rlx-umap/cuda", + "dep:rlx-runtime", + "rlx-runtime/cpu", + "rlx-runtime/gpu", + "rlx-runtime/cuda", +] +# macOS-only RLX backends (Metal + MLX). Implies `gpu`. +gpu-apple = [ + "gpu", + "rlx-umap/metal", + "rlx-umap/mlx", + "rlx-runtime/metal", + "rlx-runtime/mlx", +] +cpu = ["rlx-umap/cpu"] +# Local-only profiling (not for CI / release). Use with the `umap_hotpath` +# example: `cargo run -p skill-router --release --example umap_hotpath \ +# --features='gpu,hotpath'` +hotpath = ["dep:hotpath", "hotpath/hotpath"] +hotpath-cpu = ["dep:hotpath", "hotpath/hotpath-cpu"] +hotpath-alloc = ["dep:hotpath", "hotpath/hotpath-alloc"] [dependencies] anyhow = { workspace = true } @@ -20,13 +41,12 @@ skill-settings = { path = "../skill-settings" } serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { workspace = true } -fast-umap = { version = "1.6.0", default-features = false } -burn = { version = "0.20.1", default-features = false, features = ["ndarray"] } -burn-cubecl = { version = "0.20.1", optional = true } -burn-mlx = { git = "https://github.com/eidola-ai/burn-mlx", branch = "burn-0-20", optional = true } -cubecl = { version = "0.9.0", features = ["wgpu"], optional = true } -crossbeam-channel = "0.5.15" -half = { version = "2.4", features = ["num-traits"] } +rlx-umap = { version = "0.2.5", default-features = false, features = ["full"] } +rlx-runtime = { version = "0.2.5", default-features = false, features = ["cpu"], optional = true } +hotpath = { version = "0.16", optional = true } + +[dev-dependencies] +rlx-runtime = { version = "0.2.5", default-features = false, features = ["cpu"] } [lints] workspace = true diff --git a/crates/skill-router/build.rs b/crates/skill-router/build.rs new file mode 100644 index 00000000..63a11150 --- /dev/null +++ b/crates/skill-router/build.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Link system OpenBLAS on Linux when building with `rlx-umap` CPU / BLAS. + +mod linux_openblas { + include!("../../build-support/linux_openblas.rs"); +} + +fn main() { + linux_openblas::link_system_openblas(true); +} diff --git a/crates/skill-router/examples/umap_hotpath.rs b/crates/skill-router/examples/umap_hotpath.rs new file mode 100644 index 00000000..5146bc4c --- /dev/null +++ b/crates/skill-router/examples/umap_hotpath.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// Local-only UMAP profiling harness using hotpath-rs. +// +// Not built or run in CI. Run with: +// cargo run -p skill-router --release --example umap_hotpath \ +// --features='gpu,hotpath' +// +// Optional extras: +// --features='gpu,hotpath,hotpath-alloc' # also tracks allocations +// +// Adjust N_A / N_B below for larger/smaller workloads. + +#[cfg(all(feature = "hotpath", feature = "gpu"))] +mod runner { + use std::fs; + use std::path::PathBuf; + + const N_A: usize = 500; + const N_B: usize = 500; + + fn seed(tag: &str, n_a: usize, n_b: usize) -> (PathBuf, u64, u64, u64, u64) { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let skill_dir = std::env::temp_dir().join(format!("skill-umap-hotpath-{tag}-{nanos}")); + let day_dir = skill_dir.join("20260303"); + fs::create_dir_all(&day_dir).expect("create temp day dir"); + + let db_path = day_dir.join("eeg.sqlite"); + let conn = rusqlite::Connection::open(&db_path).expect("open db"); + conn.execute_batch( + "CREATE TABLE embeddings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + device_id TEXT, + device_name TEXT, + hnsw_id INTEGER NOT NULL, + eeg_embedding BLOB NOT NULL, + label TEXT, + extra_embedding BLOB, + metrics_json TEXT + ); + CREATE INDEX idx_timestamp ON embeddings (timestamp);", + ) + .expect("create table"); + + let a_start_ms: i64 = 1_700_000_000_000; + let b_start_ms: i64 = a_start_ms + (n_a as i64) * 250 + 60_000; + let dim = 32_usize; + let mut rng_seed: u64 = 42; + + let mut insert = conn + .prepare( + "INSERT INTO embeddings (timestamp, device_id, device_name, hnsw_id, eeg_embedding) + VALUES (?1, 'bench', 'bench', ?2, ?3)", + ) + .expect("prepare insert"); + + let mut write_epochs = |start_ms: i64, count: usize, cluster_offset: f32| { + for i in 0..count { + let ts = start_ms + (i as i64) * 250; + let emb: Vec = (0..dim) + .map(|d| { + rng_seed = rng_seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let raw = ((rng_seed >> 33) as f32) / (u32::MAX as f32) - 0.5; + raw + cluster_offset * (d as f32 / dim as f32) + }) + .collect(); + let blob: Vec = emb.iter().flat_map(|v| v.to_le_bytes()).collect(); + insert + .execute(rusqlite::params![ts, i as i64, blob]) + .expect("insert embedding"); + } + }; + + write_epochs(a_start_ms, n_a, 1.0); + write_epochs(b_start_ms, n_b, -1.0); + + drop(insert); + conn.close().ok(); + + let a_start_s = (a_start_ms / 1000) as u64; + let a_end_s = ((a_start_ms + (n_a as i64) * 250) / 1000) as u64; + let b_start_s = (b_start_ms / 1000) as u64; + let b_end_s = ((b_start_ms + (n_b as i64) * 250) / 1000) as u64; + (skill_dir, a_start_s, a_end_s, b_start_s, b_end_s) + } + + #[hotpath::main] + pub fn run() { + let (skill_dir, a_start, a_end, b_start, b_end) = seed("run", N_A, N_B); + eprintln!( + "[hotpath] seeded {} + {} embeddings in {}", + N_A, + N_B, + skill_dir.display() + ); + + match skill_router::umap_compute_inner(&skill_dir, a_start, a_end, b_start, b_end, None) { + Ok(v) => { + let backend = v["backend"].as_str().unwrap_or("?"); + let internal_ms = v["elapsed_ms"].as_u64().unwrap_or(0); + eprintln!("[hotpath] backend={backend} internal_ms={internal_ms}"); + } + Err(e) => eprintln!("[hotpath] umap_compute_inner failed: {e:#}"), + } + + let _ = fs::remove_dir_all(&skill_dir); + } +} + +#[cfg(all(feature = "hotpath", feature = "gpu"))] +fn main() { + runner::run(); +} + +#[cfg(not(all(feature = "hotpath", feature = "gpu")))] +fn main() { + eprintln!( + "umap_hotpath requires --features='hotpath,gpu'.\n\ + e.g. cargo run -p skill-router --release --example umap_hotpath \\\n\ + --features='gpu,hotpath'" + ); +} diff --git a/crates/skill-router/src/lib.rs b/crates/skill-router/src/lib.rs index 70ff8eae..59b7438d 100644 --- a/crates/skill-router/src/lib.rs +++ b/crates/skill-router/src/lib.rs @@ -15,6 +15,12 @@ use serde::Serialize; use skill_constants::{LABELS_FILE, SQLITE_FILE}; +#[cfg(feature = "gpu")] +mod umap_device; + +#[cfg(feature = "gpu")] +pub use umap_device::{device_label, resolve_umap_device}; + // ── Rounding helpers ────────────────────────────────────────────────────────── /// Round `f32` to 1 decimal place. @@ -80,6 +86,7 @@ pub struct RoundedScores { pub sample_entropy: f32, pub pac_theta_gamma: f32, pub laterality_index: f32, + pub echt: f32, pub hr: f64, pub rmssd: f64, pub sdnn: f64, @@ -109,9 +116,14 @@ pub struct RoundedScores { // ── Embedding / label loaders ───────────────────────────────────────────────── /// Load all embedding vectors from daily SQLite DBs in [start, end] UTC range. +/// +/// Uses [`skill_data::util::DualTimestampRange`] to match all three timestamp +/// formats that may be stored in the `embeddings` table (Unix ms, 14-digit +/// `YYYYMMDDHHmmss`, or 17-digit `YYYYMMDDHHmmss × 1000`). +#[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn load_embeddings_range(skill_dir: &Path, start_utc: u64, end_utc: u64) -> Vec<(u64, Vec)> { - let ts_start = (start_utc as i64) * 1000; - let ts_end = (end_utc as i64) * 1000; + let r = skill_data::util::DualTimestampRange::from_unix_secs(start_utc, end_utc); + let ts_where = skill_data::util::DualTimestampRange::WHERE_CLAUSE; let mut out: Vec<(u64, Vec)> = Vec::new(); let Ok(entries) = std::fs::read_dir(skill_dir) else { @@ -130,19 +142,29 @@ pub fn load_embeddings_range(skill_dir: &Path, start_utc: u64, end_utc: u64) -> continue; }; let _ = conn.execute_batch("PRAGMA busy_timeout=2000;"); - let Ok(mut stmt) = conn.prepare( + let Ok(mut stmt) = conn.prepare(&format!( "SELECT timestamp, eeg_embedding FROM embeddings - WHERE timestamp >= ?1 AND timestamp <= ?2 ORDER BY timestamp", - ) else { + WHERE ({ts_where}) ORDER BY timestamp" + )) else { continue; }; - let rows = stmt.query_map(rusqlite::params![ts_start, ts_end], |row| { - let ts: i64 = row.get(0)?; - let blob: Vec = row.get(1)?; - let emb: Vec = skill_data::util::blob_to_f32(&blob); - Ok(((ts / 1000) as u64, emb)) - }); + let rows = stmt.query_map( + rusqlite::params![ + r.unix_ms_start, + r.unix_ms_end, + r.dt14_start, + r.dt14_end, + r.dt17_start, + r.dt17_end + ], + |row| { + let ts: i64 = row.get(0)?; + let blob: Vec = row.get(1)?; + let emb: Vec = skill_data::util::blob_to_f32(&blob); + Ok((skill_data::util::epoch_ts_to_unix(ts), emb)) + }, + ); if let Ok(rows) = rows { for r in rows.flatten() { out.push(r); @@ -155,6 +177,7 @@ pub fn load_embeddings_range(skill_dir: &Path, start_utc: u64, end_utc: u64) -> /// Load all labels from `labels.sqlite` whose EEG window overlaps [start, end]. /// Returns Vec<(eeg_start_unix, eeg_end_unix, text)>. +#[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn load_labels_range(skill_dir: &Path, start_utc: u64, end_utc: u64) -> Vec<(u64, u64, String)> { let labels_db = skill_dir.join(LABELS_FILE); if !labels_db.exists() { @@ -193,6 +216,7 @@ pub fn find_label_for_epoch(labels: &[(u64, u64, String)], epoch_utc: u64) -> Op // ── UMAP analysis ───────────────────────────────────────────────────────────── /// Cluster analysis of UMAP 3-D projection: centroids, separation score, outliers. +#[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn analyze_umap_points( embedding: &[Vec], session_ids: &[u8], // 0 = A, 1 = B @@ -353,131 +377,39 @@ pub fn umap_cache_store(path: &Path, value: &serde_json::Value) { // ── UMAP compute ────────────────────────────────────────────────────────────── -/// GPU (wgpu/CubeCL) backend — f32. -#[cfg(feature = "gpu")] -type GpuF32 = burn::backend::Autodiff>; - -/// GPU (wgpu/CubeCL) backend — f16. -#[cfg(feature = "gpu")] -type GpuF16 = burn::backend::Autodiff>; - -/// MLX (Apple Silicon) backend — f32 only (fast-umap only implements traits for Mlx). -#[cfg(feature = "mlx")] -type MlxBackend = burn::backend::Autodiff; - -/// Resolve the effective backend string based on user preference and compiled features. -#[cfg(any(feature = "gpu", feature = "mlx"))] -fn resolve_backend(pref: &str) -> &'static str { - match pref { - "mlx" if cfg!(feature = "mlx") => "mlx", - "gpu" if cfg!(feature = "gpu") => "gpu", - _ => { - // "auto": prefer MLX on macOS when available - if cfg!(all(target_os = "macos", feature = "mlx")) { - "mlx" - } else if cfg!(feature = "gpu") { - "gpu" - } else { - "mlx" // only MLX compiled - } - } - } -} - /// Returns the list of backends available in this build. pub fn available_backends() -> Vec<&'static str> { - let mut v = Vec::new(); - if cfg!(feature = "mlx") { - v.push("mlx"); + #[cfg(feature = "gpu")] + { + umap_device::available_backends() } - if cfg!(feature = "gpu") { - v.push("gpu"); + #[cfg(not(feature = "gpu"))] + { + vec!["cpu"] } - v } /// Returns the list of precisions available for a given backend. -pub fn available_precisions(backend: &str) -> Vec<&'static str> { - match backend { - "gpu" if cfg!(feature = "gpu") => vec!["f32", "f16"], - "mlx" if cfg!(feature = "mlx") => vec!["f32"], - _ => { - // "auto" — report precisions for the resolved backend - let resolved = if cfg!(all(target_os = "macos", feature = "mlx")) { - "mlx" - } else { - "gpu" - }; - match resolved { - "gpu" => vec!["f32", "f16"], - _ => vec!["f32"], - } - } - } -} - -/// Helper: run `Umap::` fit inside catch_unwind. -macro_rules! fit_umap { - ($B:ty, $config:expr, $data:expr, $labels:expr, $on_progress:expr) => {{ - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let umap = fast_umap::Umap::<$B>::new($config); - let (_exit_tx, exit_rx) = crossbeam_channel::unbounded::<()>(); - let fitted = if let Some(cb) = $on_progress { - umap.fit_with_progress($data, Some($labels), exit_rx, cb) - } else { - umap.fit_with_signal($data, Some($labels), exit_rx) - }; - fitted.into_embedding() - })) - }}; -} - -type FitResult = Result>, Box>; - -#[cfg(feature = "gpu")] -fn fit_umap_gpu( - config: fast_umap::UmapConfig, - data: Vec>, - labels: Vec, - on_progress: Option>, - precision: &str, -) -> FitResult { - if precision == "f16" { - fit_umap!(GpuF16, config, data, labels, on_progress) - } else { - fit_umap!(GpuF32, config, data, labels, on_progress) - } -} - -#[cfg(feature = "mlx")] -fn fit_umap_mlx( - config: fast_umap::UmapConfig, - data: Vec>, - labels: Vec, - on_progress: Option>, - _precision: &str, -) -> FitResult { - // MLX only supports f32 (fast-umap trait bounds) - fit_umap!(MlxBackend, config, data, labels, on_progress) +pub fn available_precisions(_backend: &str) -> Vec<&'static str> { + vec!["f32"] } /// Inner UMAP compute — shared by both WS and Tauri IPC paths. /// -/// Uses `fast-umap` (parametric, GPU/MLX-accelerated) instead of `umap-rs` for -/// significantly faster projection on large embedding sets. +/// Uses `rlx-umap` (parametric UMAP on RLX). With the `gpu` feature, selects +/// the best available accelerator (CUDA → wgpu on Linux/Windows; Metal → MLX +/// → wgpu on macOS) and falls back to CPU. CI / `cpu`-only builds always use CPU. /// /// Results are cached to `~/.skill/umap_cache/umap_{a}_{b}_{c}_{d}.json` so /// that repeated queries for the same session pair return instantly. -/// -/// Available when the `gpu` or `mlx` feature is enabled. -#[cfg(any(feature = "gpu", feature = "mlx"))] +#[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn umap_compute_inner( skill_dir: &Path, a_start: u64, a_end: u64, b_start: u64, b_end: u64, - on_progress: Option>, + on_progress: Option>, ) -> anyhow::Result { // ── Check cache first ──────────────────────────────────────────────── let cache_path = umap_cache_path(skill_dir, a_start, a_end, b_start, b_end); @@ -490,10 +422,8 @@ pub fn umap_compute_inner( let mut embs_b = load_embeddings_range(skill_dir, b_start, b_end); let all_labels = load_labels_range(skill_dir, a_start.min(b_start), a_end.max(b_end)); - // Count total epochs (including those with empty embedding BLOBs) let total_a = embs_a.len(); let total_b = embs_b.len(); - // Filter to only epochs that have actual embedding vectors embs_a.retain(|e| !e.1.is_empty()); embs_b.retain(|e| !e.1.is_empty()); let n_a = embs_a.len(); @@ -536,12 +466,10 @@ pub fn umap_compute_inner( })); } - // ── Load user-configurable UMAP parameters ───────────────────────────── let ucfg = skill_settings::load_umap_config(skill_dir); let n_use = n; - // Build Vec> input expected by fast-umap. let mut data: Vec> = Vec::with_capacity(n_use); let mut timestamps: Vec = Vec::with_capacity(n_use); let mut labels: Vec = Vec::with_capacity(n_use); @@ -554,74 +482,54 @@ pub fn umap_compute_inner( let k = ucfg.n_neighbors.clamp(2, 50).min(n_use - 1).min(n_use / 2).max(2); let n_epochs = ucfg.n_epochs.clamp(50, 2000); - let config = fast_umap::UmapConfig { + let config = rlx_umap::UmapConfig { n_components: 3, - graph: fast_umap::GraphParams { + graph: rlx_umap::config::GraphParams { n_neighbors: k, ..Default::default() }, - optimization: fast_umap::OptimizationParams { + optimization: rlx_umap::config::OptimizationParams { n_epochs, verbose: false, repulsion_strength: ucfg.repulsion_strength.clamp(0.1, 10.0), neg_sample_rate: ucfg.neg_sample_rate.clamp(1, 30), timeout: Some(ucfg.timeout_secs.clamp(10, 600)), cooldown_ms: ucfg.cooldown_ms.clamp(0, 10_000), - figures_dir: Some(skill_dir.join("tmp/figures")), ..Default::default() }, ..Default::default() }; - let fit_labels: Vec = (0..n_use) - .map(|i| { - let session_tag = if labels[i] == 0 { "A" } else { "B" }; - if let Some(lbl) = find_label_for_epoch(&all_labels, timestamps[i]) { - format!("{session_tag}:{lbl}") - } else { - session_tag.to_string() - } - }) - .collect(); + let device = select_umap_device(&ucfg.backend); + let backend = device.1; + eprintln!("[umap] backend: {backend} (user pref: {:?})", ucfg.backend); - // ── Backend dispatch ───────────────────────────────────────────────── - let effective = resolve_backend(&ucfg.backend); - let precision = match ucfg.precision.as_str() { - "f16" => "f16", - _ => "f32", - }; - eprintln!( - "[umap] backend: {effective}/{precision} (user pref: {:?}/{:?})", - ucfg.backend, ucfg.precision - ); - - #[cfg(all(feature = "mlx", feature = "gpu"))] - let fit_result = if effective == "mlx" { - fit_umap_mlx(config, data, fit_labels, on_progress, precision) - } else { - fit_umap_gpu(config, data, fit_labels, on_progress, precision) - }; + rlx_umap::register(); - #[cfg(all(feature = "mlx", not(feature = "gpu")))] - let fit_result = fit_umap_mlx(config, data, fit_labels, on_progress, precision); - - #[cfg(all(feature = "gpu", not(feature = "mlx")))] - let fit_result = fit_umap_gpu(config, data, fit_labels, on_progress, precision); - - let embedding = match fit_result { - Ok(emb) => emb, - Err(e) => { - let msg = if let Some(s) = e.downcast_ref::() { - s.clone() - } else if let Some(s) = e.downcast_ref::<&str>() { - s.to_string() - } else { - "unknown panic".to_string() - }; - eprintln!("[umap] UMAP fit panicked: {msg}"); - anyhow::bail!("UMAP projection failed: {msg}") + let fitted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let umap = if device.0 == rlx_umap::Device::Cpu { + rlx_umap::Umap::new(config) + } else { + rlx_umap::Umap::with_device(config, device.0) + }; + if let Some(cb) = on_progress { + umap.fit_with_progress(data, move |p| cb(p)) + } else { + umap.fit(data) } - }; + })) + .map_err(|e| { + let msg = if let Some(s) = e.downcast_ref::() { + s.clone() + } else if let Some(s) = e.downcast_ref::<&str>() { + s.to_string() + } else { + "unknown panic".to_string() + }; + anyhow::anyhow!("UMAP projection failed: {msg}") + })?; + + let embedding = fitted.embedding; let points: Vec = (0..n_use) .map(|i| { @@ -642,7 +550,7 @@ pub fn umap_compute_inner( .collect(); let elapsed_ms = umap_start.elapsed().as_millis() as u64; - eprintln!("[umap] projection done in {elapsed_ms} ms ({n_use} embeddings, backend: {effective}/{precision})"); + eprintln!("[umap] projection done in {elapsed_ms} ms ({n_use} embeddings, backend: {backend})"); let analysis = analyze_umap_points(&embedding, &labels, ×tamps, n_a); @@ -653,41 +561,25 @@ pub fn umap_compute_inner( "dim": dim, "elapsed_ms": elapsed_ms, "analysis": analysis, - "backend": effective, - "precision": precision, + "backend": backend, }); - // ── Persist to cache ───────────────────────────────────────────────── umap_cache_store(&cache_path, &result); Ok(result) } -// CPU-only UMAP stub (for CI coverage without GPU/MLX) -#[cfg(not(any(feature = "gpu", feature = "mlx")))] -pub fn umap_compute_inner( - _skill_dir: &Path, - _a_start: u64, - _a_end: u64, - _b_start: u64, - _b_end: u64, - _on_progress: Option>, -) -> anyhow::Result { - Ok(serde_json::json!({ - "points": [], - "n_a": 0, - "n_b": 0, - "dim": 0, - "elapsed_ms": 0, - "backend": "none", - "analysis": { - "density_a": 0.0, - "density_b": 0.0, - "mixing_score": 0.0, - "cluster_count": 0, - "avg_distance": 0.0 - } - })) +fn select_umap_device(pref: &str) -> (rlx_umap::Device, &'static str) { + #[cfg(feature = "gpu")] + { + let device = umap_device::resolve_umap_device(pref); + (device, umap_device::device_label(device)) + } + #[cfg(not(feature = "gpu"))] + { + let _ = pref; + (rlx_umap::Device::Cpu, "cpu") + } } // ── Supported commands ──────────────────────────────────────────────────────── diff --git a/crates/skill-router/src/umap_device.rs b/crates/skill-router/src/umap_device.rs new file mode 100644 index 00000000..0765f275 --- /dev/null +++ b/crates/skill-router/src/umap_device.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Runtime RLX device resolution for UMAP projection. + +use rlx_runtime::device_ext::is_available; +use rlx_umap::Device; + +/// Short backend label stored in UMAP cache JSON and logs. +pub fn device_label(device: Device) -> &'static str { + match device { + Device::Metal => "metal", + Device::Mlx => "mlx", + Device::Cuda => "cuda", + Device::Gpu => "gpu", + Device::Rocm => "rocm", + Device::Cpu => "cpu", + _ => "other", + } +} + +/// Resolve the best available RLX device for UMAP projection. +/// +/// Respects the user's `UmapUserConfig::backend` preference string: +/// +/// | value | behaviour | +/// |----------|------------------------------------------------| +/// | `"auto"` | platform default (see below), CPU fallback | +/// | `"cpu"` | always CPU | +/// | `"metal"`| Apple Metal; CPU if unavailable | +/// | `"mlx"` | Apple MLX; CPU if unavailable | +/// | `"cuda"` | NVIDIA CUDA; CPU if unavailable | +/// | `"gpu"` | wgpu; CPU if unavailable | +/// | `"rocm"` | AMD ROCm; CPU if unavailable | +/// +/// Platform defaults for `"auto"`: +/// - **macOS**: Metal → MLX → wgpu → CPU +/// - **Linux / Windows**: CUDA → wgpu → CPU +pub fn resolve_umap_device(pref: &str) -> Device { + match pref { + "cpu" => return Device::Cpu, + "metal" => { + return if is_available(Device::Metal) { + Device::Metal + } else { + Device::Cpu + }; + } + "mlx" => { + return if is_available(Device::Mlx) { + Device::Mlx + } else { + Device::Cpu + }; + } + "cuda" => { + return if is_available(Device::Cuda) { + Device::Cuda + } else { + Device::Cpu + }; + } + "gpu" => { + return if is_available(Device::Gpu) { + Device::Gpu + } else { + Device::Cpu + }; + } + "rocm" => { + return if is_available(Device::Rocm) { + Device::Rocm + } else { + Device::Cpu + }; + } + _ => {} // "auto" or unknown — fall through to platform defaults + } + + #[cfg(target_os = "macos")] + { + if is_available(Device::Metal) { + return Device::Metal; + } + if is_available(Device::Mlx) { + return Device::Mlx; + } + if is_available(Device::Gpu) { + return Device::Gpu; + } + } + #[cfg(not(target_os = "macos"))] + { + if is_available(Device::Cuda) { + return Device::Cuda; + } + if is_available(Device::Gpu) { + return Device::Gpu; + } + } + + Device::Cpu +} + +/// Backends compiled into this build that pass a runtime availability probe. +pub fn available_backends() -> Vec<&'static str> { + let mut v = Vec::new(); + for (device, label) in [ + (Device::Metal, "metal"), + (Device::Mlx, "mlx"), + (Device::Cuda, "cuda"), + (Device::Gpu, "gpu"), + (Device::Rocm, "rocm"), + ] { + if is_available(device) { + v.push(label); + } + } + v.push("cpu"); + v +} diff --git a/crates/skill-router/tests/umap_e2e_bench.rs b/crates/skill-router/tests/umap_e2e_bench.rs index 10c569b1..acc09f38 100644 --- a/crates/skill-router/tests/umap_e2e_bench.rs +++ b/crates/skill-router/tests/umap_e2e_bench.rs @@ -1,17 +1,21 @@ // SPDX-License-Identifier: GPL-3.0-only // Copyright (C) 2026 NeuroSkill.com // -// End-to-end UMAP benchmark on synthetic EEG embeddings. +// End-to-end UMAP benchmark on synthetic EEG embeddings (rlx-umap). // // Run with: -// cargo test -p skill-router --features gpu -- umap_e2e --nocapture -// cargo test -p skill-router --features mlx -- umap_e2e --nocapture -// cargo test -p skill-router --features gpu,mlx -- umap_e2e --nocapture +// cargo test -p skill-router -- umap_e2e_small --nocapture +// cargo test -p skill-router -- umap_e2e --ignored --include-ignored --nocapture use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::time::Instant; -/// Create a temporary skill_dir with a daily SQLite DB containing `n` synthetic +use rlx_runtime::device_ext; +use rlx_umap::config::{GraphParams, OptimizationParams, UmapConfig}; +use rlx_umap::{Device, Umap}; + +/// Create a temporary skill_dir with a daily SQLite DB containing synthetic /// 32-dimensional EEG embeddings spread across two sessions (A and B). /// /// Returns `(skill_dir, a_start, a_end, b_start, b_end)`. @@ -89,140 +93,220 @@ fn seed_synthetic_embeddings(tag: &str, n_a: usize, n_b: usize) -> (PathBuf, u64 (skill_dir, a_start_s, a_end_s, b_start_s, b_end_s) } -/// Run `umap_compute_inner` on the synthetic data and return elapsed milliseconds. +/// Load session embeddings from SQLite and return `(matrix, n_a, n_b)`. +fn load_session_matrix( + skill_dir: &Path, + a_start: u64, + a_end: u64, + b_start: u64, + b_end: u64, +) -> (Vec>, usize, usize) { + let embs_a = skill_router::load_embeddings_range(skill_dir, a_start, a_end); + let embs_b = skill_router::load_embeddings_range(skill_dir, b_start, b_end); + let n_a = embs_a.len(); + let n_b = embs_b.len(); + let data: Vec> = embs_a + .into_iter() + .chain(embs_b) + .map(|(_, emb)| emb.into_iter().map(f64::from).collect()) + .collect(); + (data, n_a, n_b) +} + +fn bench_config(n: usize, n_epochs: usize) -> UmapConfig { + let k = 15_usize.clamp(2, 50).min(n.saturating_sub(1)).min(n / 2).max(2); + UmapConfig { + n_components: 3, + graph: GraphParams { + n_neighbors: k, + ..Default::default() + }, + optimization: OptimizationParams { + n_epochs, + verbose: false, + ..Default::default() + }, + ..Default::default() + } +} + +fn device_label(device: Device) -> &'static str { + match device { + Device::Metal => "metal", + Device::Mlx => "mlx", + Device::Cuda => "cuda", + Device::Gpu => "gpu", + Device::Rocm => "rocm", + Device::Cpu => "cpu", + _ => "other", + } +} + +struct BenchResult { + wall_ms: u64, + internal_ms: u64, + backend: &'static str, + n_a: usize, + n_b: usize, + embedding: Vec>, +} + +/// Run rlx-umap on SQLite-backed synthetic data. /// -/// Returns `None` when the host has no usable GPU adapter (e.g. headless Linux -/// CI runners without Vulkan ICDs). Lets the test eprintln-and-skip rather -/// than fail in environments where the hardware prerequisite is absent. -#[cfg(any(feature = "gpu", feature = "mlx"))] +/// Returns `None` when the requested device is unavailable (e.g. headless CI +/// without wgpu/Vulkan). Lets the caller skip rather than fail. fn run_umap_bench( - skill_dir: &std::path::Path, + skill_dir: &Path, a_start: u64, a_end: u64, b_start: u64, b_end: u64, -) -> Option<(u64, serde_json::Value)> { - let wall_start = std::time::Instant::now(); - match skill_router::umap_compute_inner(skill_dir, a_start, a_end, b_start, b_end, None) { - Ok(value) => Some((wall_start.elapsed().as_millis() as u64, value)), - Err(e) => { - let msg = format!("{e:#}"); - if msg.contains("adapter") || msg.contains("Vulkan") || msg.contains("backend") { - eprintln!("[umap] skipping bench — no usable GPU adapter: {msg}"); - None - } else { - panic!("umap_compute_inner failed: {msg}"); - } - } + device: Device, + n_epochs: usize, +) -> Option { + if !device_ext::is_available(device) { + eprintln!("[umap] skip: {device:?} not available"); + return None; + } + + rlx_umap::register(); + + let (data, n_a, n_b) = load_session_matrix(skill_dir, a_start, a_end, b_start, b_end); + let n = data.len(); + assert!(n >= 5, "seed should produce at least 5 embeddings"); + + let config = bench_config(n, n_epochs); + let backend = device_label(device); + + let wall_start = Instant::now(); + let fit_start = Instant::now(); + let fitted = if device == Device::Cpu { + Umap::new(config).fit(data) + } else { + Umap::with_device(config, device).fit(data) + }; + let internal_ms = fit_start.elapsed().as_millis() as u64; + let wall_ms = wall_start.elapsed().as_millis() as u64; + + Some(BenchResult { + wall_ms, + internal_ms, + backend, + n_a, + n_b, + embedding: fitted.embedding, + }) +} + +fn report_bench(name: &str, result: &BenchResult) { + let points_len = result.embedding.len(); + eprintln!("── {name} ──"); + eprintln!(" backend: {}", result.backend); + eprintln!(" n_a={} n_b={} total={points_len} dim=32", result.n_a, result.n_b); + eprintln!(" internal: {} ms", result.internal_ms); + eprintln!(" wall (w/ I/O): {} ms", result.wall_ms); + eprintln!( + " throughput: {:.0} pts/sec", + points_len as f64 / (result.wall_ms.max(1) as f64 / 1000.0) + ); +} + +fn assert_projection(result: &BenchResult) { + let points_len = result.embedding.len(); + assert_eq!(points_len, result.n_a + result.n_b, "all points projected"); + assert!(result.n_a >= 5, "enough session A points"); + assert!(result.n_b >= 5, "enough session B points"); + + let p0 = &result.embedding[0]; + assert_eq!(p0.len(), 3, "point has 3D coordinates"); + for (i, v) in p0.iter().enumerate() { + assert!(v.is_finite(), "coordinate {i} is finite"); } } // ── Tests ─────────────────────────────────────────────────────────────────── -/// Small dataset (200 points) — sanity check + fast CI. +/// Small dataset (200 points) — sanity check + fast CI (CPU, no GPU required). #[test] -#[cfg(any(feature = "gpu", feature = "mlx"))] fn umap_e2e_small() { let (skill_dir, a_start, a_end, b_start, b_end) = seed_synthetic_embeddings("small", 100, 100); - let Some((wall_ms, result)) = run_umap_bench(&skill_dir, a_start, a_end, b_start, b_end) else { + let Some(result) = run_umap_bench(&skill_dir, a_start, a_end, b_start, b_end, Device::Cpu, 50) else { let _ = fs::remove_dir_all(&skill_dir); - return; + panic!("CPU UMAP should always be available"); }; - let n_a = result["n_a"].as_u64().unwrap(); - let n_b = result["n_b"].as_u64().unwrap(); - let points_len = result["points"].as_array().map(|a| a.len()).unwrap_or(0); - let internal_ms = result["elapsed_ms"].as_u64().unwrap_or(0); - let backend = result["backend"].as_str().unwrap_or("?"); - - eprintln!("── umap_e2e_small ──"); - eprintln!(" backend: {backend}"); - eprintln!(" n_a={n_a} n_b={n_b} total={points_len} dim=32"); - eprintln!(" internal: {internal_ms} ms"); - eprintln!(" wall (w/ I/O): {wall_ms} ms"); - eprintln!( - " throughput: {:.0} pts/sec", - points_len as f64 / (wall_ms.max(1) as f64 / 1000.0) - ); - - assert_eq!(points_len as u64, n_a + n_b, "all points projected"); - assert!(n_a >= 5, "enough session A points"); - assert!(n_b >= 5, "enough session B points"); + report_bench("umap_e2e_small", &result); + assert_projection(&result); - // Verify 3D coordinates exist - let p0 = &result["points"][0]; - assert!(p0["x"].is_f64(), "point has x coordinate"); - assert!(p0["y"].is_f64(), "point has y coordinate"); - assert!(p0["z"].is_f64(), "point has z coordinate"); + let labels: Vec = (0..result.n_a).map(|_| 0).chain((0..result.n_b).map(|_| 1)).collect(); + let timestamps: Vec = (0..result.embedding.len() as u64).collect(); + let analysis = skill_router::analyze_umap_points(&result.embedding, &labels, ×tamps, result.n_a); + assert!(analysis["separation_score"].is_number()); let _ = fs::remove_dir_all(skill_dir); } -/// Medium dataset (1000 points) — representative of a typical EEG session pair. +/// Medium dataset (1000 points) — GPU/Metal benchmark when hardware is present. #[test] -#[cfg(any(feature = "gpu", feature = "mlx"))] +#[ignore = "slow benchmark; run with --include-ignored or via npm run test:mlx-e2e"] +#[cfg(feature = "gpu")] fn umap_e2e_medium() { let (skill_dir, a_start, a_end, b_start, b_end) = seed_synthetic_embeddings("medium", 500, 500); - let Some((wall_ms, result)) = run_umap_bench(&skill_dir, a_start, a_end, b_start, b_end) else { + let Some(result) = run_umap_bench( + &skill_dir, + a_start, + a_end, + b_start, + b_end, + skill_router::resolve_umap_device("auto"), + 200, + ) else { let _ = fs::remove_dir_all(&skill_dir); return; }; - let n_a = result["n_a"].as_u64().unwrap(); - let n_b = result["n_b"].as_u64().unwrap(); - let points_len = result["points"].as_array().map(|a| a.len()).unwrap_or(0); - let internal_ms = result["elapsed_ms"].as_u64().unwrap_or(0); - let backend = result["backend"].as_str().unwrap_or("?"); - let sep = result["analysis"]["separation_score"].as_f64().unwrap_or(0.0); - - eprintln!("── umap_e2e_medium ──"); - eprintln!(" backend: {backend}"); - eprintln!(" n_a={n_a} n_b={n_b} total={points_len} dim=32"); - eprintln!(" internal: {internal_ms} ms"); - eprintln!(" wall (w/ I/O): {wall_ms} ms"); - eprintln!( - " throughput: {:.0} pts/sec", - points_len as f64 / (wall_ms.max(1) as f64 / 1000.0) - ); - eprintln!(" separation: {sep:.3}"); + report_bench("umap_e2e_medium", &result); + assert_eq!(result.embedding.len(), result.n_a + result.n_b); - assert_eq!(points_len as u64, n_a + n_b); + let labels: Vec = (0..result.n_a).map(|_| 0).chain((0..result.n_b).map(|_| 1)).collect(); + let timestamps: Vec = (0..result.embedding.len() as u64).collect(); + let analysis = skill_router::analyze_umap_points(&result.embedding, &labels, ×tamps, result.n_a); + let sep = analysis["separation_score"].as_f64().unwrap_or(0.0); + eprintln!(" separation: {sep:.3}"); let _ = fs::remove_dir_all(skill_dir); } /// Large dataset (5000 points) — stress test matching real-world cache sizes. #[test] -#[cfg(any(feature = "gpu", feature = "mlx"))] +#[ignore = "slow benchmark; run with --include-ignored or via npm run test:mlx-e2e"] +#[cfg(feature = "gpu")] fn umap_e2e_large() { let (skill_dir, a_start, a_end, b_start, b_end) = seed_synthetic_embeddings("large", 2500, 2500); - let Some((wall_ms, result)) = run_umap_bench(&skill_dir, a_start, a_end, b_start, b_end) else { + let Some(result) = run_umap_bench( + &skill_dir, + a_start, + a_end, + b_start, + b_end, + skill_router::resolve_umap_device("auto"), + 500, + ) else { let _ = fs::remove_dir_all(&skill_dir); return; }; - let n_a = result["n_a"].as_u64().unwrap(); - let n_b = result["n_b"].as_u64().unwrap(); - let points_len = result["points"].as_array().map(|a| a.len()).unwrap_or(0); - let internal_ms = result["elapsed_ms"].as_u64().unwrap_or(0); - let backend = result["backend"].as_str().unwrap_or("?"); - let sep = result["analysis"]["separation_score"].as_f64().unwrap_or(0.0); - - eprintln!("── umap_e2e_large ──"); - eprintln!(" backend: {backend}"); - eprintln!(" n_a={n_a} n_b={n_b} total={points_len} dim=32"); - eprintln!(" internal: {internal_ms} ms"); - eprintln!(" wall (w/ I/O): {wall_ms} ms"); - eprintln!( - " throughput: {:.0} pts/sec", - points_len as f64 / (wall_ms.max(1) as f64 / 1000.0) - ); - eprintln!(" separation: {sep:.3}"); + report_bench("umap_e2e_large", &result); + assert_eq!(result.embedding.len(), result.n_a + result.n_b); - assert_eq!(points_len as u64, n_a + n_b); + let labels: Vec = (0..result.n_a).map(|_| 0).chain((0..result.n_b).map(|_| 1)).collect(); + let timestamps: Vec = (0..result.embedding.len() as u64).collect(); + let analysis = skill_router::analyze_umap_points(&result.embedding, &labels, ×tamps, result.n_a); + let sep = analysis["separation_score"].as_f64().unwrap_or(0.0); + eprintln!(" separation: {sep:.3}"); let _ = fs::remove_dir_all(skill_dir); } diff --git a/crates/skill-screenshots/Cargo.toml b/crates/skill-screenshots/Cargo.toml index d52380bc..2057aadd 100644 --- a/crates/skill-screenshots/Cargo.toml +++ b/crates/skill-screenshots/Cargo.toml @@ -4,6 +4,7 @@ version = "0.0.1" edition = "2021" license = "GPL-3.0-only" description = "Screenshot capture + vision embedding for NeuroSkill" +build = "build.rs" [dependencies] skill-constants = { path = "../skill-constants" } @@ -18,10 +19,14 @@ image = { version = "0.25", default-features = false, features = ["png gif = "0.14" fast-hnsw = "1.0.1" crossbeam-channel = "0.5" -ocrs = "0.12.1" -rten = "0.24.0" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } ureq = { version = "3", features = ["native-tls"] } +hf-hub = { version = "0.5", default-features = false, features = ["ureq"], optional = true } +rlx = { workspace = true, optional = true, features = ["cpu"] } +# `rlx-models` provides the `ocr` submodule (rlx-ocr drop-in replacement +# for the legacy `ocrs` crate). Always-on so OCR can use the rlx runtime +# stack without an extra feature flag. +rlx-models = { workspace = true } [[bin]] name = "image_encode_bench" @@ -31,24 +36,37 @@ path = "src/bin/image_encode_bench.rs" version = "=2.0.0-rc.11" default-features = false features = ["coreml"] +optional = true [target.'cfg(not(target_os = "macos"))'.dependencies.ort] version = "=2.0.0-rc.11" default-features = false features = ["download-binaries", "tls-native"] +optional = true [target.'cfg(target_os = "macos")'.dependencies] skill-vision = { path = "../skill-vision" } -fastembed = { version = "5.11.0", features = ["metal"] } +fastembed = { version = "5.11.0", features = ["metal"], optional = true } objc2 = "0.6" objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication"] } [features] -default = ["capture"] +# Backend selection mirrors skill-daemon-state: +# * text-embeddings-fastembed — fastembed + ort+rten (ONNX runtime), ~30 MB extra +# * text-embeddings-rlx — RlxVisionModel from rlx-embed (NomicEmbedVisionV15) +# When neither is on the capture pipeline still works (no embeddings emitted). +# OCR (ocrs+rten) stays compiled in — rten is its own runtime, not ONNX. +default = ["capture", "text-embeddings-rlx"] capture = ["xcap"] +text-embeddings-fastembed = ["dep:fastembed", "dep:ort"] +text-embeddings-rlx = ["dep:rlx", "dep:hf-hub"] +text-embeddings-rlx-metal = ["text-embeddings-rlx", "rlx?/metal", "rlx?/blas-accelerate", "rlx-models/metal"] +text-embeddings-rlx-cuda = ["text-embeddings-rlx", "rlx?/cuda", "rlx-models/cuda"] +text-embeddings-rlx-rocm = ["text-embeddings-rlx", "rlx?/rocm", "rlx-models/rocm"] +text-embeddings-rlx-wgpu = ["text-embeddings-rlx", "rlx?/gpu", "rlx-models/gpu"] [target.'cfg(not(target_os = "macos"))'.dependencies] -fastembed = { version = "5.11.0", features = ["ort-download-binaries-native-tls"] } +fastembed = { version = "5.11.0", features = ["ort-download-binaries-native-tls"], optional = true } xcap = { version = "0.9", default-features = false, optional = true } [lints] diff --git a/crates/skill-screenshots/build.rs b/crates/skill-screenshots/build.rs new file mode 100644 index 00000000..95852d28 --- /dev/null +++ b/crates/skill-screenshots/build.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Link system OpenBLAS on Linux when building with `text-embeddings-rlx`. + +mod linux_openblas { + include!("../../build-support/linux_openblas.rs"); +} + +fn main() { + let enabled = std::env::var_os("CARGO_FEATURE_TEXT_EMBEDDINGS_RLX").is_some(); + linux_openblas::link_system_openblas(enabled); +} diff --git a/crates/skill-screenshots/src/capture.rs b/crates/skill-screenshots/src/capture.rs index f319e68c..8ca7ef3d 100644 --- a/crates/skill-screenshots/src/capture.rs +++ b/crates/skill-screenshots/src/capture.rs @@ -181,6 +181,7 @@ fn save_hnsw(idx: &LabeledIndex, skill_dir: &Path) { /// Each provider's `build()` returns a registration request; if ORT was not /// compiled with that EP's feature flag the registration silently fails and /// the next provider in the list is tried, ultimately falling through to CPU. +#[cfg(feature = "text-embeddings-fastembed")] fn build_execution_providers(use_gpu: bool) -> Vec { if use_gpu { #[cfg(target_os = "macos")] @@ -212,11 +213,13 @@ fn build_execution_providers(use_gpu: bool) -> Vec Option { load_fastembed_image(config, skill_dir) } /// Try to create a fastembed `ImageEmbedding` instance. +#[cfg(feature = "text-embeddings-fastembed")] fn load_fastembed_image(config: &ScreenshotConfig, _skill_dir: &Path) -> Option { if config.embed_backend != "fastembed" { return None; @@ -244,11 +247,13 @@ fn load_fastembed_image(config: &ScreenshotConfig, _skill_dir: &Path) -> Option< } /// Embed a single image (PNG bytes) using fastembed. Public alias for Tauri commands. +#[cfg(feature = "text-embeddings-fastembed")] pub fn fastembed_embed_pub(encoder: &mut fastembed::ImageEmbedding, png_bytes: &[u8]) -> Option> { fastembed_embed(encoder, png_bytes) } /// Embed a single image (PNG bytes) using fastembed. +#[cfg(feature = "text-embeddings-fastembed")] fn fastembed_embed(encoder: &mut fastembed::ImageEmbedding, png_bytes: &[u8]) -> Option> { match encoder.embed_bytes(&[png_bytes], None) { Ok(mut vecs) if !vecs.is_empty() => Some(vecs.remove(0)), @@ -262,6 +267,7 @@ fn fastembed_embed(encoder: &mut fastembed::ImageEmbedding, png_bytes: &[u8]) -> /// Embed a pre-decoded `DynamicImage` directly using fastembed — avoids the /// CPU-intensive PNG encode→decode round-trip that `embed_bytes` performs. +#[cfg(feature = "text-embeddings-fastembed")] #[allow(dead_code)] fn fastembed_embed_image(encoder: &mut fastembed::ImageEmbedding, img: DynamicImage) -> Option> { match encoder.embed_images(vec![img]) { @@ -320,8 +326,11 @@ fn download_ocr_model(url: &str, dest: &Path) -> bool { } } -/// Load the ocrs OCR engine. Downloads model files on first use. -fn load_ocr_engine(skill_dir: &Path) -> Option { +use rlx_models::ocr as rlx_ocr; + +/// Load the OCR engine via `rlx-ocr` (rlx-models' drop-in replacement +/// for the legacy `ocrs` crate). Downloads model files on first use. +fn load_ocr_engine(skill_dir: &Path) -> Option { let ocr_dir = skill_dir.join("ocr_models"); let det_path = ocr_dir.join(OCR_DETECTION_MODEL_FILE); let rec_path = ocr_dir.join(OCR_RECOGNITION_MODEL_FILE); @@ -333,50 +342,41 @@ fn load_ocr_engine(skill_dir: &Path) -> Option { return None; } - let det_model = rten::Model::load_file(&det_path).ok()?; - let rec_model = rten::Model::load_file(&rec_path).ok()?; - - ocrs::OcrEngine::new(ocrs::OcrEngineParams { - detection_model: Some(det_model), - recognition_model: Some(rec_model), + rlx_ocr::OcrEngine::new(rlx_ocr::OcrEngineParams { + detection_model: Some(det_path.clone()), + recognition_model: Some(rec_path.clone()), ..Default::default() }) .ok() } -/// Run OCR on an already-resized PNG image. Returns the extracted text. -/// -/// On macOS: uses `skill-vision` crate (compiled ObjC, Vision framework, -/// GPU / Neural Engine) — typically <50 ms. +/// Run OCR on an already-resized PNG image. Returns the extracted text. /// -/// On other platforms (or if Apple Vision fails): uses `ocrs` (rten, CPU). -fn run_ocr(engine: &ocrs::OcrEngine, png_bytes: &[u8]) -> Option { - // Try Apple Vision first on macOS (GPU/ANE, <50ms) +/// On macOS: tries `skill-vision` (Apple Vision, GPU / Neural Engine, <50 ms). +/// Falls back to `rlx-ocr` everywhere else. +fn run_ocr(engine: &rlx_ocr::OcrEngine, png_bytes: &[u8]) -> Option { #[cfg(target_os = "macos")] { if let Some(text) = skill_vision::recognize_text_from_png(png_bytes) { return Some(text); } - // Fall through to ocrs if Vision framework fails } run_ocr_rten(engine, png_bytes) } -/// OCR via the `ocrs` crate (rten CPU inference). Used on Linux/Windows +/// OCR via `rlx-ocr` (rten-inference backend, CPU). Used on Linux/Windows /// and as a fallback on macOS if Vision framework is unavailable. -fn run_ocr_rten(engine: &ocrs::OcrEngine, png_bytes: &[u8]) -> Option { +fn run_ocr_rten(engine: &rlx_ocr::OcrEngine, png_bytes: &[u8]) -> Option { let img = image::load_from_memory(png_bytes).ok()?.into_rgb8(); run_ocr_rten_rgb(engine, &img) } /// OCR from an already-decoded `DynamicImage` — avoids the encode→decode /// round-trip when the caller already has pixel data. -fn run_ocr_from_image(engine: &ocrs::OcrEngine, img: &DynamicImage) -> Option { - // Try Apple Vision first on macOS (GPU/ANE, <50ms) +fn run_ocr_from_image(engine: &rlx_ocr::OcrEngine, img: &DynamicImage) -> Option { #[cfg(target_os = "macos")] { - // Apple Vision needs encoded bytes — JPEG is ~10× faster than PNG if let Some(jpg) = encode_jpeg(img, 85) { if let Some(text) = skill_vision::recognize_text_from_png(&jpg) { return Some(text); @@ -389,9 +389,9 @@ fn run_ocr_from_image(engine: &ocrs::OcrEngine, img: &DynamicImage) -> Option Option { +fn run_ocr_rten_rgb(engine: &rlx_ocr::OcrEngine, img: &image::RgbImage) -> Option { let (w, h) = img.dimensions(); - let source = ocrs::ImageSource::from_bytes(img.as_raw(), (w, h)).ok()?; + let source = rlx_ocr::ImageSource::from_bytes(img.as_raw(), (w, h)).ok()?; let input = engine.prepare_input(source).ok()?; let text = engine.get_text(&input).ok()?; let text = text.trim().to_string(); diff --git a/crates/skill-screenshots/src/config.rs b/crates/skill-screenshots/src/config.rs index 0221bd65..a065fbbc 100644 --- a/crates/skill-screenshots/src/config.rs +++ b/crates/skill-screenshots/src/config.rs @@ -13,6 +13,7 @@ pub use skill_settings::ScreenshotConfig; /// /// This lives here (not in `skill-settings`) because it depends on the /// `fastembed` crate which is only available in `skill-screenshots`. +#[cfg(feature = "text-embeddings-fastembed")] pub fn fastembed_model_enum(config: &ScreenshotConfig) -> Option { match config.fastembed_model.as_str() { "clip-vit-b-32" => Some(fastembed::ImageEmbeddingModel::ClipVitB32), diff --git a/crates/skill-screenshots/src/lib.rs b/crates/skill-screenshots/src/lib.rs index 2890ff44..c2488d12 100644 --- a/crates/skill-screenshots/src/lib.rs +++ b/crates/skill-screenshots/src/lib.rs @@ -14,6 +14,8 @@ pub mod context; #[allow(dead_code)] pub(crate) mod gif_encode; pub(crate) mod platform; +#[cfg(feature = "text-embeddings-rlx")] +pub mod rlx_image; pub mod user_screenshot; // Re-export so existing `skill_screenshots::ScreenshotConfig` paths keep working. diff --git a/crates/skill-screenshots/src/rlx_image.rs b/crates/skill-screenshots/src/rlx_image.rs new file mode 100644 index 00000000..df54c0ab --- /dev/null +++ b/crates/skill-screenshots/src/rlx_image.rs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! RLX-backed image embedder (alternative to fastembed). +//! +//! Loads `nomic-ai/nomic-embed-vision-v1.5` (the one image-embedding family +//! covered by `rlx-embed`) from HuggingFace and forwards a single image +//! through the compiled RLX graph. Returns a 768-dim L2-normalized vector. +//! +//! The whole module is gated on `text-embeddings-rlx` so it disappears +//! cleanly in fastembed-only builds. + +#![cfg(feature = "text-embeddings-rlx")] + +use anyhow::{anyhow, Result}; +use image::{imageops::FilterType, DynamicImage, GenericImageView, ImageReader}; +use rlx_models::RlxVisionModel; +use std::io::Cursor; +use std::sync::Mutex; + +/// Encoder handle — owns the compiled `RlxVisionModel` + image-size knobs. +pub struct RlxImageEmbedder { + inner: Mutex, + img_size: usize, +} + +impl RlxImageEmbedder { + /// Resolve nomic-embed-vision-v1.5 from HF cache and compile its graph. + pub fn from_repo(device: &str) -> Result { + let device = parse_device(device); + let repo = hf_hub::api::sync::ApiBuilder::new() + .with_progress(true) + .build()? + .model("nomic-ai/nomic-embed-vision-v1.5".to_string()); + let config_path = repo.get("config.json")?; + let weights_path = repo.get("model.safetensors")?; + let weights_path = weights_path + .to_str() + .ok_or_else(|| anyhow!("non-utf8 weights path"))? + .to_string(); + let model = RlxVisionModel::load_sized_on(&config_path, &weights_path, 1, device)?; + let img_size = model.img_size(); + Ok(Self { + inner: Mutex::new(model), + img_size, + }) + } + + /// Embed a single image (PNG / JPEG / WebP bytes). Returns a 768-dim + /// L2-normalized vector or `None` on decode/forward failure. + pub fn embed_bytes(&self, bytes: &[u8]) -> Option> { + let img = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .ok()? + .decode() + .ok()?; + self.embed_image(&img) + } + + /// Embed an already-decoded `DynamicImage`. + pub fn embed_image(&self, img: &DynamicImage) -> Option> { + let target = self.img_size as u32; + let resized = resize_center_crop(img, target); + let pixels = to_nchw_f32(&resized, self.img_size); + let mut guard = self.inner.lock().ok()?; + let mut out = guard.forward(&pixels, 1); + if out.is_empty() { + return None; + } + l2_normalize(&mut out); + Some(out) + } + + pub fn dim(&self) -> usize { + // NomicEmbedVisionV15: hidden=768. + self.inner.lock().map(|m| m.hidden_size()).unwrap_or(768) + } +} + +fn parse_device(s: &str) -> rlx::Device { + match s.trim().to_ascii_lowercase().as_str() { + "metal" => rlx::Device::Metal, + "mlx" => rlx::Device::Mlx, + "cuda" => rlx::Device::Cuda, + "rocm" => rlx::Device::Rocm, + "gpu" | "wgpu" => rlx::Device::Gpu, + _ => rlx::Device::Cpu, + } +} + +/// Center-crop after aspect-preserving resize to the model input size. +fn resize_center_crop(img: &DynamicImage, target: u32) -> DynamicImage { + let (w, h) = img.dimensions(); + let scale = (target as f64 / w.min(h) as f64).max(1.0); + let nw = (w as f64 * scale).round() as u32; + let nh = (h as f64 * scale).round() as u32; + let resized = img.resize(nw, nh, FilterType::Triangle); + let cx = (nw.saturating_sub(target)) / 2; + let cy = (nh.saturating_sub(target)) / 2; + resized.crop_imm(cx, cy, target, target) +} + +/// Convert RGB image → NCHW f32 `[1, 3, H, W]`. Normalises with CLIP-style +/// per-channel mean/std (NomicEmbedVision uses the same constants per its +/// preprocessor_config.json). +fn to_nchw_f32(img: &DynamicImage, target: usize) -> Vec { + #[allow(clippy::excessive_precision)] + const MEAN: [f32; 3] = [0.48145466, 0.4578275, 0.40821073]; + #[allow(clippy::excessive_precision)] + const STD: [f32; 3] = [0.26862954, 0.26130258, 0.27577711]; + let rgb = img.to_rgb8(); + let (w, h) = rgb.dimensions(); + debug_assert_eq!(w as usize, target); + debug_assert_eq!(h as usize, target); + let mut out = vec![0f32; 3 * target * target]; + for y in 0..target { + for x in 0..target { + let px = rgb.get_pixel(x as u32, y as u32); + for c in 0..3 { + let v = px.0[c] as f32 / 255.0; + out[c * target * target + y * target + x] = (v - MEAN[c]) / STD[c]; + } + } + } + out +} + +fn l2_normalize(v: &mut [f32]) { + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + for x in v.iter_mut() { + *x /= norm; + } + } +} diff --git a/crates/skill-screenshots/tests/context_tests.rs b/crates/skill-screenshots/tests/context_tests.rs index b0aa65cd..17c4818c 100644 --- a/crates/skill-screenshots/tests/context_tests.rs +++ b/crates/skill-screenshots/tests/context_tests.rs @@ -2,7 +2,9 @@ //! Unit tests for the screenshot context and config types. use serde_json::Value; -use skill_screenshots::config::{fastembed_model_enum, ScreenshotConfig}; +#[cfg(feature = "text-embeddings-fastembed")] +use skill_screenshots::config::fastembed_model_enum; +use skill_screenshots::config::ScreenshotConfig; use skill_screenshots::context::{ActiveWindowInfo, ScreenshotContext}; /// Minimal mock context for testing. @@ -40,6 +42,7 @@ fn mock_context_default_config() { } #[test] +#[cfg(feature = "text-embeddings-fastembed")] fn fastembed_model_clip() { let mut cfg = ScreenshotConfig::default(); cfg.fastembed_model = "clip-vit-b-32".into(); @@ -47,6 +50,7 @@ fn fastembed_model_clip() { } #[test] +#[cfg(feature = "text-embeddings-fastembed")] fn fastembed_model_nomic() { let mut cfg = ScreenshotConfig::default(); cfg.fastembed_model = "nomic-embed-vision-v1.5".into(); @@ -54,6 +58,7 @@ fn fastembed_model_nomic() { } #[test] +#[cfg(feature = "text-embeddings-fastembed")] fn fastembed_model_unknown_returns_none() { let mut cfg = ScreenshotConfig::default(); cfg.fastembed_model = "unknown-model".into(); diff --git a/crates/skill-settings/Cargo.toml b/crates/skill-settings/Cargo.toml index 3021e7ab..dad810b5 100644 --- a/crates/skill-settings/Cargo.toml +++ b/crates/skill-settings/Cargo.toml @@ -16,6 +16,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" keyring = "3.6" +tracing = "0.1" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3.6", features = ["apple-native"] } @@ -26,6 +27,9 @@ keyring = { version = "3.6", features = ["linux-native-sync-persisten [target.'cfg(target_os = "windows")'.dependencies] keyring = { version = "3.6", features = ["windows-native"] } +[dev-dependencies] +tempfile = "3" + [features] default = [] llm = [] diff --git a/crates/skill-settings/src/keychain.rs b/crates/skill-settings/src/keychain.rs index 82f6865a..dcfef5b8 100644 --- a/crates/skill-settings/src/keychain.rs +++ b/crates/skill-settings/src/keychain.rs @@ -10,11 +10,59 @@ //! Secrets survive app re-installs and build updates because they live in //! the system credential store, not in the app data directory. +#[cfg(not(debug_assertions))] use keyring::Entry; /// Service name used as the keychain namespace for all NeuroSkill secrets. +#[cfg(not(debug_assertions))] const SERVICE: &str = "com.neuroskill.skill"; +// ── Debug-build in-memory store ────────────────────────────────────────────── +// +// Debug builds (`cargo run`, `tauri dev`, `cargo test`) deliberately avoid the +// OS keychain — every rebuild produces a binary with a different code +// signature, which on macOS triggers a fresh authorization prompt. The dev +// loop becomes unbearable. +// +// Pre-this commit the workaround was to short-circuit getters to `""` and +// setters to no-op, but that broke any code (including unit tests) that +// expected `set` then `get` to roundtrip. We now keep a process-local +// `Mutex` instead — no OS prompt, but values survive within the same +// process so the route handlers behave like real keychain code. +// +// Release builds bypass this entirely and use `keyring::Entry`. + +#[cfg(debug_assertions)] +mod dev_store { + use std::collections::HashMap; + use std::sync::Mutex; + use std::sync::OnceLock; + + static STORE: OnceLock>> = OnceLock::new(); + + fn store() -> &'static Mutex> { + STORE.get_or_init(|| Mutex::new(HashMap::new())) + } + + pub fn get(key: &str) -> String { + store() + .lock() + .ok() + .and_then(|g| g.get(key).cloned()) + .unwrap_or_default() + } + + pub fn set(key: &str, value: &str) { + if let Ok(mut g) = store().lock() { + if value.is_empty() { + g.remove(key); + } else { + g.insert(key.to_string(), value.to_string()); + } + } + } +} + // ── Key names ───────────────────────────────────────────────────────────────── const KEY_API_TOKEN: &str = "api_token"; @@ -27,7 +75,13 @@ const KEY_NEUROSITY_PASSWORD: &str = "neurosity_password"; const KEY_NEUROSITY_DEVICE_ID: &str = "neurosity_device_id"; // ── Low-level helpers ───────────────────────────────────────────────────────── +// +// In debug builds these route through `dev_store` (process-local, no OS +// keychain access). In release they hit the real OS keychain. Per-secret +// helpers above don't need their own `cfg!(debug_assertions)` checks — the +// switch happens here so the callers behave identically in both modes. +#[cfg(not(debug_assertions))] fn get_secret(key: &str) -> String { match Entry::new(SERVICE, key).and_then(|e| e.get_password()) { Ok(v) => v, @@ -39,6 +93,12 @@ fn get_secret(key: &str) -> String { } } +#[cfg(debug_assertions)] +fn get_secret(key: &str) -> String { + dev_store::get(key) +} + +#[cfg(not(debug_assertions))] fn set_secret(key: &str, value: &str) { let entry = match Entry::new(SERVICE, key) { Ok(e) => e, @@ -53,13 +113,16 @@ fn set_secret(key: &str, value: &str) { Ok(()) | Err(keyring::Error::NoEntry) => {} Err(e) => eprintln!("[keychain] failed to delete {key}: {e}"), } - } else { - if let Err(e) = entry.set_password(value) { - eprintln!("[keychain] failed to store {key}: {e}"); - } + } else if let Err(e) = entry.set_password(value) { + eprintln!("[keychain] failed to store {key}: {e}"); } } +#[cfg(debug_assertions)] +fn set_secret(key: &str, value: &str) { + dev_store::set(key, value); +} + // ── Public API ──────────────────────────────────────────────────────────────── /// All secret fields managed by the keychain. @@ -75,18 +138,84 @@ pub struct Secrets { pub neurosity_device_id: String, } -/// Load all secrets from the system keychain. +// ── Lazy per-secret accessors ───────────────────────────────────────────────── +// +// macOS prompts for keychain access whenever the calling binary's code +// signature doesn't match the ACL on a stored item. A fresh app build has +// a fresh signature, so eagerly reading every secret at startup produces +// one prompt per item per process, before the user has done anything. +// +// These accessors read individual entries on demand, so the OS keychain +// prompt only appears when the user initiates an action that actually needs +// the secret (e.g. clicking "Connect Emotiv" or opening the device settings +// tab). In debug builds the low-level helpers route through `dev_store` +// instead of the OS keychain, so dev/test workflows roundtrip values without +// any auth dialogs. + +pub fn get_api_token() -> String { + get_secret(KEY_API_TOKEN) +} + +pub fn set_api_token(value: &str) { + set_secret(KEY_API_TOKEN, value); +} + +pub fn get_emotiv_credentials() -> (String, String) { + (get_secret(KEY_EMOTIV_CLIENT_ID), get_secret(KEY_EMOTIV_CLIENT_SECRET)) +} + +pub fn get_idun_api_token() -> String { + get_secret(KEY_IDUN_API_TOKEN) +} + +pub fn get_oura_access_token() -> String { + get_secret(KEY_OURA_ACCESS_TOKEN) +} + +pub fn get_neurosity_credentials() -> (String, String, String) { + ( + get_secret(KEY_NEUROSITY_EMAIL), + get_secret(KEY_NEUROSITY_PASSWORD), + get_secret(KEY_NEUROSITY_DEVICE_ID), + ) +} + +pub fn get_neurosity_device_id() -> String { + get_secret(KEY_NEUROSITY_DEVICE_ID) +} + +/// Write device-API secrets supplied in `secrets` to the keychain. /// -/// In debug builds the keychain is **skipped** entirely to avoid macOS -/// Keychain authorization dialogs on every `cargo run` / `tauri dev` -/// (the dev binary has a different code signature each build, so macOS -/// asks for permission every time). Secrets fall back to the JSON -/// settings file which still contains them in dev mode. -pub fn load_secrets() -> Secrets { - if cfg!(debug_assertions) { - eprintln!("[keychain] skipping keychain in debug build"); - return Secrets::default(); +/// Empty fields are **ignored** rather than treated as deletion: if the user +/// denies a keychain prompt during the GET round-trip, the in-memory copy of +/// untouched secrets will be empty, and we don't want to clobber valid stored +/// values on the next save. Use [`set_api_token`] (or extend with explicit +/// delete helpers) when an empty value is genuinely meant to clear. +/// +/// Used by the daemon's `set_device_api_config` route. +pub fn save_device_api_secrets(secrets: &Secrets) { + let pairs: &[(&str, &str)] = &[ + (KEY_EMOTIV_CLIENT_ID, &secrets.emotiv_client_id), + (KEY_EMOTIV_CLIENT_SECRET, &secrets.emotiv_client_secret), + (KEY_IDUN_API_TOKEN, &secrets.idun_api_token), + (KEY_OURA_ACCESS_TOKEN, &secrets.oura_access_token), + (KEY_NEUROSITY_EMAIL, &secrets.neurosity_email), + (KEY_NEUROSITY_PASSWORD, &secrets.neurosity_password), + (KEY_NEUROSITY_DEVICE_ID, &secrets.neurosity_device_id), + ]; + for &(key, value) in pairs { + if !value.is_empty() { + set_secret(key, value); + } } +} + +/// Load all secrets eagerly from the keychain. +/// +/// Retained only for the legacy round-trip through [`save_secrets`] used by +/// the Tauri shell's `save_settings_now`. New code should use the per-secret +/// accessors above so prompts only fire on user-initiated actions. +pub fn load_secrets() -> Secrets { Secrets { api_token: get_secret(KEY_API_TOKEN), emotiv_client_id: get_secret(KEY_EMOTIV_CLIENT_ID), @@ -101,19 +230,28 @@ pub fn load_secrets() -> Secrets { /// Save all secrets to the system keychain. /// -/// No-op in debug builds (see [`load_secrets`] for rationale). +/// Empty values are **ignored** rather than treated as a deletion request. +/// This avoids clobbering previously-stored secrets when the caller's +/// in-memory copy was never populated (e.g. lazy-load callers that don't +/// hydrate every field). Use the dedicated `set_*` helpers above to +/// explicitly delete a secret. +/// pub fn save_secrets(secrets: &Secrets) { - if cfg!(debug_assertions) { - return; + let pairs: &[(&str, &str)] = &[ + (KEY_API_TOKEN, &secrets.api_token), + (KEY_EMOTIV_CLIENT_ID, &secrets.emotiv_client_id), + (KEY_EMOTIV_CLIENT_SECRET, &secrets.emotiv_client_secret), + (KEY_IDUN_API_TOKEN, &secrets.idun_api_token), + (KEY_OURA_ACCESS_TOKEN, &secrets.oura_access_token), + (KEY_NEUROSITY_EMAIL, &secrets.neurosity_email), + (KEY_NEUROSITY_PASSWORD, &secrets.neurosity_password), + (KEY_NEUROSITY_DEVICE_ID, &secrets.neurosity_device_id), + ]; + for &(key, value) in pairs { + if !value.is_empty() { + set_secret(key, value); + } } - set_secret(KEY_API_TOKEN, &secrets.api_token); - set_secret(KEY_EMOTIV_CLIENT_ID, &secrets.emotiv_client_id); - set_secret(KEY_EMOTIV_CLIENT_SECRET, &secrets.emotiv_client_secret); - set_secret(KEY_IDUN_API_TOKEN, &secrets.idun_api_token); - set_secret(KEY_OURA_ACCESS_TOKEN, &secrets.oura_access_token); - set_secret(KEY_NEUROSITY_EMAIL, &secrets.neurosity_email); - set_secret(KEY_NEUROSITY_PASSWORD, &secrets.neurosity_password); - set_secret(KEY_NEUROSITY_DEVICE_ID, &secrets.neurosity_device_id); } /// Migrate plaintext secrets from settings JSON into the keychain. @@ -123,9 +261,6 @@ pub fn save_secrets(secrets: &Secrets) { /// into the keychain. Returns `true` if any migration happened (caller /// should re-save settings to strip the plaintext values). pub fn migrate_plaintext_secrets(secrets: &Secrets) -> bool { - if cfg!(debug_assertions) { - return false; - } let mut migrated = false; let pairs: &[(&str, &str)] = &[ diff --git a/crates/skill-settings/src/lib.rs b/crates/skill-settings/src/lib.rs index c5533bb8..58d63b79 100644 --- a/crates/skill-settings/src/lib.rs +++ b/crates/skill-settings/src/lib.rs @@ -28,7 +28,7 @@ pub use skill_tts::config::default_neutts_backbone_repo; pub use skill_tts::NeuttsConfig; // Re-export LLM config types from skill-llm. -pub use skill_llm::config::{LlmConfig, LlmToolConfig, ToolExecutionMode}; +pub use skill_llm::config::{LlmConfig, LlmInferenceRuntime, LlmToolConfig, ToolExecutionMode}; // Screenshot configuration — defined locally to avoid pulling in the heavy // skill-screenshots crate (xcap → pipewire on Linux) for settings I/O. @@ -320,7 +320,7 @@ pub struct UmapUserConfig { pub n_neighbors: usize, /// Milliseconds to sleep between training epochs (0 = max throughput). pub cooldown_ms: u64, - /// Compute backend: "auto", "mlx", or "gpu". + /// Compute backend: "auto", "cpu", "metal", "mlx", "cuda", "gpu", or "rocm". /// "auto" selects MLX on macOS when available, GPU otherwise. pub backend: String, /// Floating-point precision: "f32" or "f16". @@ -542,6 +542,22 @@ pub fn default_daily_goal_min() -> u32 { pub fn default_embedding_model() -> String { "nomic-ai/nomic-embed-text-v1.5".into() } +pub fn default_text_embedding_backend() -> String { + "fastembed".into() +} +pub fn default_label_index_backend() -> String { + "hnsw".into() +} +pub fn default_text_embedding_rlx_device() -> String { + if cfg!(target_os = "macos") { + "metal".into() + } else { + "cpu".into() + } +} +pub fn default_text_embedding_rlx_max_seq() -> usize { + 512 +} pub fn default_overlap_secs() -> f32 { EMBEDDING_OVERLAP_SECS } @@ -854,6 +870,14 @@ pub struct UserSettings { pub goal_notified_date: String, #[serde(default = "default_embedding_model")] pub text_embedding_model: String, + #[serde(default = "default_text_embedding_backend")] + pub text_embedding_backend: String, + #[serde(default = "default_label_index_backend")] + pub label_index_backend: String, + #[serde(default = "default_text_embedding_rlx_device")] + pub text_embedding_rlx_device: String, + #[serde(default = "default_text_embedding_rlx_max_seq")] + pub text_embedding_rlx_max_seq: usize, #[serde(default)] pub hooks: Vec, /// WebSocket server bind host. @@ -948,6 +972,11 @@ pub struct UserSettings { /// Recording storage format: `"csv"` (default), `"parquet"`, or `"both"`. #[serde(default = "default_storage_format")] pub storage_format: String, + /// Roll the session writer to a new file every N minutes. Bounds the + /// blast radius of a daemon crash to ≤ N minutes of data and keeps any + /// single file small enough for readers to load. `0` disables rollover. + #[serde(default = "default_session_rollover_minutes")] + pub session_rollover_minutes: u32, /// Background scanner backend toggles. #[serde(default)] pub scanner: ScannerConfig, @@ -989,10 +1018,9 @@ pub struct UserSettings { #[serde(default = "default_llm_gpu_layers_saved")] pub llm_gpu_layers_saved: u32, - /// Inference backend for EXG embeddings: `"auto"`, `"mlx"`, `"gpu"`, or `"cpu"`. + /// Inference backend for EXG embeddings: `"auto"`, `"gpu"`, or `"cpu"`. /// - /// `"auto"` selects MLX on macOS when available, GPU (wgpu) otherwise. - /// `"mlx"` uses Apple Silicon native acceleration via burn-mlx. + /// `"auto"` selects GPU (wgpu) when available, CPU otherwise. /// `"gpu"` uses the wgpu backend (Metal on macOS, Vulkan on Linux). /// `"cpu"` uses burn's NdArray backend (no GPU required, slower). #[serde(default = "default_exg_inference_device")] @@ -1060,6 +1088,10 @@ pub struct ReembedConfig { /// Milliseconds to sleep between epochs during background reembed. /// Higher values reduce CPU/GPU contention with other daemon tasks. pub idle_reembed_throttle_ms: u64, + /// Skip starting an idle reembed run when system memory usage (used / total) + /// is above this percent. Avoids OOM'ing the user's machine when other apps + /// already have heavy RSS. Set to 100 to disable. Re-evaluated each loop tick. + pub max_resident_memory_percent: u8, } fn default_daemon_auto_restart() -> bool { @@ -1081,7 +1113,12 @@ impl Default for ReembedConfig { idle_reembed_delay_secs: 1800, // 30 minutes idle_reembed_gpu: true, gpu_precision: "f16".into(), - idle_reembed_throttle_ms: 10, + // Sleep between epochs during background reembed. The previous + // default (10ms) drove the daemon to ~100% CPU on machines without + // a discrete GPU; 200ms keeps a typical day's backlog finishing + // overnight while leaving headroom for foreground work. + idle_reembed_throttle_ms: 200, + max_resident_memory_percent: 85, } } } @@ -1097,6 +1134,10 @@ pub fn default_storage_format() -> String { "csv".into() } +pub fn default_session_rollover_minutes() -> u32 { + 60 +} + pub fn default_tts_preload() -> bool { true } @@ -1233,6 +1274,10 @@ impl Default for UserSettings { daily_goal_min: default_daily_goal_min(), goal_notified_date: String::new(), text_embedding_model: default_embedding_model(), + text_embedding_backend: default_text_embedding_backend(), + label_index_backend: default_label_index_backend(), + text_embedding_rlx_device: default_text_embedding_rlx_device(), + text_embedding_rlx_max_seq: default_text_embedding_rlx_max_seq(), hooks: Vec::new(), ws_host: default_ws_host(), ws_port: default_ws_port(), @@ -1257,6 +1302,7 @@ impl Default for UserSettings { llm: LlmConfig::default(), accent_color: default_accent_color(), storage_format: default_storage_format(), + session_rollover_minutes: default_session_rollover_minutes(), screenshot: ScreenshotConfig::default(), sleep: SleepConfig::default(), scanner: ScannerConfig::default(), @@ -1282,6 +1328,7 @@ fn default_brainmaster_model() -> String { pub fn load_settings(skill_dir: &Path) -> UserSettings { let path = settings_path(skill_dir); let mut s: UserSettings = skill_data::util::load_json_or_default(&path); + let mut json_dirty = false; // ── Shortcut migrations ────────────────────────────────────────────── if s.search_shortcut == "CmdOrCtrl+Shift+F" { @@ -1291,6 +1338,19 @@ pub fn load_settings(skill_dir: &Path) -> UserSettings { s.settings_shortcut = default_settings_shortcut(); } + // ── Idle re-embed throttle migration ───────────────────────────────── + // The original default was 10 ms, which kept the encoder running flat + // out and pinned a core to ~100% on machines without a fast GPU. The new + // default is 200 ms. We can't tell whether a user-saved 10 was an + // explicit choice or just the previous default, but anyone who set 10 + // intentionally was almost certainly hitting the same CPU complaint — + // so promote the value either way and re-save so the migration sticks. + if s.reembed.idle_reembed_throttle_ms == 10 { + tracing::info!("[settings] migrating idle_reembed_throttle_ms 10 -> 200 (CPU-pinning legacy default)"); + s.reembed.idle_reembed_throttle_ms = 200; + json_dirty = true; + } + // ── Secret migration: plaintext JSON → system keychain ─────────────── // // If the JSON file still contains non-empty secret values (from a @@ -1306,27 +1366,23 @@ pub fn load_settings(skill_dir: &Path) -> UserSettings { neurosity_password: s.device_api.neurosity_password.clone(), neurosity_device_id: s.device_api.neurosity_device_id.clone(), }); - if migrated { + if migrated || json_dirty { // Re-save without the secret fields (skip_serializing takes care of it). if let Ok(json) = serde_json::to_string_pretty(&s) { let _ = std::fs::write(&path, &json); } } - // ── Load secrets from keychain (release) or keep JSON values (debug) ── - if !cfg!(debug_assertions) { - let secrets = keychain::load_secrets(); - s.api_token = secrets.api_token; - s.device_api.emotiv_client_id = secrets.emotiv_client_id; - s.device_api.emotiv_client_secret = secrets.emotiv_client_secret; - s.device_api.idun_api_token = secrets.idun_api_token; - s.device_api.oura_access_token = secrets.oura_access_token; - s.device_api.neurosity_email = secrets.neurosity_email; - s.device_api.neurosity_password = secrets.neurosity_password; - s.device_api.neurosity_device_id = secrets.neurosity_device_id; - } - // In debug mode, secrets stay as loaded from the JSON file — no keychain - // interaction, no macOS authorization prompts on every dev build. + // Secrets are deliberately **not** hydrated here. Loading every secret at + // startup triggers one macOS keychain prompt per item per process whenever + // the binary's code signature changes (i.e. on every release upgrade), and + // `load_settings` is called by both the Tauri shell and the daemon during + // boot. Callers that actually need a secret read it on demand from + // `keychain::get_*`, so a prompt only appears when the user initiates an + // action that requires that specific secret. + // + // In debug builds, secrets stay as loaded from the JSON file (the JSON + // round-trip is preserved by `skip_secret_in_release` returning false). s } diff --git a/crates/skill-settings/src/tests.rs b/crates/skill-settings/src/tests.rs index 8ba757d3..baf504f6 100644 --- a/crates/skill-settings/src/tests.rs +++ b/crates/skill-settings/src/tests.rs @@ -294,6 +294,23 @@ fn user_settings_from_empty_json() { assert_eq!(s.daily_goal_min, default_daily_goal_min()); } +#[test] +fn reembed_config_max_resident_memory_defaults_and_migrates() { + // Default value is exposed. + let cfg = ReembedConfig::default(); + assert_eq!(cfg.max_resident_memory_percent, 85); + + // Roundtrip preserves it. + let json = serde_json::to_string(&cfg).unwrap(); + let back: ReembedConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.max_resident_memory_percent, 85); + + // Older settings files without the field deserialize cleanly using the default. + let legacy = r#"{ "idle_reembed_enabled": true, "idle_reembed_throttle_ms": 200 }"#; + let migrated: ReembedConfig = serde_json::from_str(legacy).unwrap(); + assert_eq!(migrated.max_resident_memory_percent, 85); +} + #[test] fn umap_user_config_default_roundtrip() { let cfg = UmapUserConfig::default(); @@ -324,3 +341,49 @@ fn default_values_are_sensible() { assert!(default_update_check_interval() > 0); assert!(!default_hf_endpoint().is_empty()); } + +// ── Migrations ────────────────────────────────────────────────────────── + +#[test] +fn idle_reembed_throttle_old_default_is_migrated() { + let dir = tempfile::tempdir().expect("tempdir"); + let skill_dir = dir.path(); + let path = settings_path(skill_dir); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + + // Write a settings file with the legacy 10ms value (the CPU-pinning default). + std::fs::write( + &path, + serde_json::json!({ "reembed": { "idle_reembed_throttle_ms": 10 } }).to_string(), + ) + .unwrap(); + + let s = load_settings(skill_dir); + assert_eq!(s.reembed.idle_reembed_throttle_ms, 200, "old 10ms must be promoted"); + + // Re-save must persist the new value so the migration runs once, not on every load. + let on_disk: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!( + on_disk["reembed"]["idle_reembed_throttle_ms"].as_u64(), + Some(200), + "migrated value must be written back to disk" + ); +} + +#[test] +fn idle_reembed_throttle_user_chosen_value_is_preserved() { + let dir = tempfile::tempdir().expect("tempdir"); + let skill_dir = dir.path(); + let path = settings_path(skill_dir); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + + // Any value other than the legacy 10 must be left alone. + std::fs::write( + &path, + serde_json::json!({ "reembed": { "idle_reembed_throttle_ms": 50 } }).to_string(), + ) + .unwrap(); + + let s = load_settings(skill_dir); + assert_eq!(s.reembed.idle_reembed_throttle_ms, 50); +} diff --git a/crates/skill-tools/src/lib.rs b/crates/skill-tools/src/lib.rs index 1db9654b..f36f2ff4 100644 --- a/crates/skill-tools/src/lib.rs +++ b/crates/skill-tools/src/lib.rs @@ -25,9 +25,12 @@ pub mod log; #[macro_export] macro_rules! tool_log { ($tag:expr, $($arg:tt)*) => { - if $crate::log::log_enabled() { - $crate::log::write_log($tag, &format!($($arg)*)); - } + ::skill_constants::subsystem_log!( + $crate::log::log_enabled, + $crate::log::write_log, + $tag, + $($arg)* + ); }; } diff --git a/crates/skill-tts/Cargo.toml b/crates/skill-tts/Cargo.toml index 6fc079f0..031ceaae 100644 --- a/crates/skill-tts/Cargo.toml +++ b/crates/skill-tts/Cargo.toml @@ -4,6 +4,7 @@ version = "0.0.1" edition = "2021" license = "GPL-3.0-only" description = "TTS engine for NeuroSkill — extracted workspace crate" +build = "build.rs" [features] default = [] @@ -19,15 +20,20 @@ tokio = { version = "1", features = ["sync"] } hf-hub = "0.5" dirs = "6" -kittentts = { version = "0.4.1", features = ["espeak"], optional = true } rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-wav"], optional = true } sha2 = { version = "0.10", optional = true } hound = { version = "3", optional = true } -neutts = { version = "0.1.1", features = ["backbone", "espeak", "fast", "wgpu"], optional = true } +neutts = { path = "../skill-neutts", package = "skill-neutts", features = ["espeak"], optional = true } + +# KittenTTS pulls in `ort` (ONNX Runtime). We keep it off Windows so the +# installer doesn't have to ship onnxruntime.dll. Cargo treats `dep:kittentts` +# in the `tts-kitten` feature as a no-op on targets where the dep is absent. +[target.'cfg(not(target_os = "windows"))'.dependencies] +kittentts = { version = "0.4.1", features = ["espeak"], optional = true } [target.'cfg(target_os = "macos")'.dependencies] -neutts = { version = "0.1.1", features = ["backbone", "espeak", "fast", "wgpu", "metal"], optional = true } +neutts = { path = "../skill-neutts", package = "skill-neutts", features = ["espeak", "metal"], optional = true } [lints] workspace = true diff --git a/crates/skill-tts/build.rs b/crates/skill-tts/build.rs new file mode 100644 index 00000000..31bd007d --- /dev/null +++ b/crates/skill-tts/build.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Emits `tts_kitten_active` cfg when the `tts-kitten` feature is enabled +//! AND the host target ships the optional `kittentts` dep (non-Windows). +//! Source code uses `cfg(tts_kitten_active)` instead of the bare feature +//! gate so Windows builds skip the kitten code path even when the feature +//! is on (e.g. via the `default` features chain in src-tauri). + +fn main() { + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_TTS_KITTEN"); + println!("cargo::rustc-check-cfg=cfg(tts_kitten_active)"); + let feat_on = std::env::var_os("CARGO_FEATURE_TTS_KITTEN").is_some(); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if feat_on && target_os != "windows" { + println!("cargo:rustc-cfg=tts_kitten_active"); + } +} diff --git a/crates/skill-tts/src/lib.rs b/crates/skill-tts/src/lib.rs index 40ad9f6b..c80cd65b 100644 --- a/crates/skill-tts/src/lib.rs +++ b/crates/skill-tts/src/lib.rs @@ -23,26 +23,29 @@ pub mod log; #[allow(unused_macros)] macro_rules! tts_log { ($tag:expr, $($arg:tt)*) => { - if $crate::log::log_enabled() { - $crate::log::write_log($tag, &format!($($arg)*)); - } + ::skill_constants::subsystem_log!( + $crate::log::log_enabled, + $crate::log::write_log, + $tag, + $($arg)* + ) }; } -#[cfg(feature = "tts-kitten")] +#[cfg(tts_kitten_active)] pub mod kitten; #[cfg(feature = "tts-neutts")] pub mod neutts; -#[cfg(any(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(any(tts_kitten_active, feature = "tts-neutts"))] use anyhow::Context; -#[cfg(any(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(any(tts_kitten_active, feature = "tts-neutts"))] use std::num::NonZero; use std::path::PathBuf; -#[cfg(all(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(all(tts_kitten_active, feature = "tts-neutts"))] use std::sync::atomic::AtomicBool; -#[cfg(any(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(any(tts_kitten_active, feature = "tts-neutts"))] use std::sync::atomic::Ordering; use std::sync::OnceLock; @@ -99,7 +102,7 @@ pub fn set_logging(enable: bool) { // ─── Shared constants ───────────────────────────────────────────────────────── -#[cfg(any(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(any(tts_kitten_active, feature = "tts-neutts"))] pub use skill_constants::TTS_TAIL_SILENCE_SECS as TAIL_SILENCE_SECS; // ─── Progress event ─────────────────────────────────────────────────────────── @@ -115,7 +118,7 @@ pub struct TtsProgressEvent { pub use skill_constants::TTS_PROGRESS_EVENT; -#[cfg(any(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(any(tts_kitten_active, feature = "tts-neutts"))] impl TtsProgressEvent { pub fn step(step: u32, total: u32, label: String) -> Self { Self { @@ -161,12 +164,12 @@ impl TtsProgressEvent { pub fn init_espeak_bundled_data_path(_resource_dir: &std::path::Path) {} /// No-op — espeak-ng data is now bundled in the Rust crate. -#[cfg(any(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(any(tts_kitten_active, feature = "tts-neutts"))] pub fn init_espeak_data_path() {} // ─── Shared audio output ────────────────────────────────────────────────────── -#[cfg(any(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(any(tts_kitten_active, feature = "tts-neutts"))] pub fn play_f32_audio(stream: &rodio::MixerDeviceSink, mut samples: Vec, sample_rate: u32) { use rodio::buffer::SamplesBuffer; @@ -184,25 +187,25 @@ pub fn play_f32_audio(stream: &rodio::MixerDeviceSink, mut samples: Vec, sa // ─── Back-end routing ───────────────────────────────────────────────────────── -#[cfg(all(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(all(tts_kitten_active, feature = "tts-neutts"))] pub static NEUTTS_ENABLED: AtomicBool = AtomicBool::new(false); -#[cfg(all(feature = "tts-kitten", feature = "tts-neutts"))] +#[cfg(all(tts_kitten_active, feature = "tts-neutts"))] pub fn use_neutts() -> bool { NEUTTS_ENABLED.load(Ordering::Relaxed) } -#[cfg(all(feature = "tts-neutts", not(feature = "tts-kitten")))] +#[cfg(all(feature = "tts-neutts", not(tts_kitten_active)))] pub fn use_neutts() -> bool { true } -#[cfg(all(feature = "tts-kitten", not(feature = "tts-neutts")))] +#[cfg(all(tts_kitten_active, not(feature = "tts-neutts")))] pub fn use_neutts() -> bool { false } -#[cfg(not(any(feature = "tts-kitten", feature = "tts-neutts")))] +#[cfg(not(any(tts_kitten_active, feature = "tts-neutts")))] pub fn use_neutts() -> bool { false } @@ -211,7 +214,7 @@ pub fn use_neutts() -> bool { /// Synchronously drop all TTS backends before process exit. pub fn tts_shutdown() { - #[cfg(feature = "tts-kitten")] + #[cfg(tts_kitten_active)] { let timeout = std::time::Duration::from_secs(8); let (tx, rx) = std::sync::mpsc::sync_channel::<()>(0); @@ -254,7 +257,7 @@ pub struct NeuttsVoiceInfo { /// Speak `text` aloud using the active TTS backend. pub async fn tts_speak(text: String, voice: Option) { let voice_str = voice.unwrap_or_default(); - #[cfg(not(any(feature = "tts-kitten", feature = "tts-neutts")))] + #[cfg(not(any(tts_kitten_active, feature = "tts-neutts")))] { let _ = (&text, &voice_str); } @@ -276,7 +279,7 @@ pub async fn tts_speak(text: String, voice: Option) { let _ = rx.await; } } else { - #[cfg(feature = "tts-kitten")] + #[cfg(tts_kitten_active)] { let resolved_voice = if voice_str.is_empty() || voice_str == "default" { kitten::get_voice() @@ -303,7 +306,7 @@ pub fn tts_list_voices() -> Vec { .map(std::string::ToString::to_string) .collect(); } else { - #[cfg(feature = "tts-kitten")] + #[cfg(tts_kitten_active)] return kitten::AVAILABLE_VOICES .get() .cloned() @@ -361,7 +364,7 @@ pub fn tts_get_voice() -> String { return preset; } } else { - #[cfg(feature = "tts-kitten")] + #[cfg(tts_kitten_active)] return kitten::get_voice(); } #[allow(unreachable_code)] @@ -371,7 +374,7 @@ pub fn tts_get_voice() -> String { /// Set the active voice name. #[allow(clippy::needless_pass_by_value)] // consumed by neutts::set_voice_preset when feature is enabled pub fn tts_set_voice(voice: String) { - #[cfg(not(any(feature = "tts-kitten", feature = "tts-neutts")))] + #[cfg(not(any(tts_kitten_active, feature = "tts-neutts")))] let _ = &voice; if use_neutts() { #[cfg(feature = "tts-neutts")] @@ -381,7 +384,7 @@ pub fn tts_set_voice(voice: String) { } } } else { - #[cfg(feature = "tts-kitten")] + #[cfg(tts_kitten_active)] { let voices = kitten::AVAILABLE_VOICES .get() @@ -438,7 +441,7 @@ pub async fn tts_init_with_callback anyhow::Result<()> { return Ok(()); } } else { - #[cfg(feature = "tts-kitten")] + #[cfg(tts_kitten_active)] { let (tx, rx) = tokio::sync::oneshot::channel(); kitten::get_tx() diff --git a/crates/skill-tts/src/log.rs b/crates/skill-tts/src/log.rs index f8f541d9..4796d12e 100644 --- a/crates/skill-tts/src/log.rs +++ b/crates/skill-tts/src/log.rs @@ -80,15 +80,20 @@ pub fn write_log(tag: &str, msg: &str) { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + // Serialize all tests that read or write the global ENABLED flag. + static ENABLED_LOCK: Mutex<()> = Mutex::new(()); #[test] fn log_enabled_by_default() { - // Note: other tests may have toggled this, so we just check the function works - let _ = log_enabled(); + let _g = ENABLED_LOCK.lock().unwrap(); + assert!(log_enabled()); } #[test] fn set_enabled_toggles() { + let _g = ENABLED_LOCK.lock().unwrap(); set_log_enabled(false); assert!(!log_enabled()); set_log_enabled(true); @@ -97,12 +102,14 @@ mod tests { #[test] fn write_log_does_not_panic_without_callback() { + let _g = ENABLED_LOCK.lock().unwrap(); set_log_enabled(true); write_log("test", "hello from test"); } #[test] fn write_log_noop_when_disabled() { + let _g = ENABLED_LOCK.lock().unwrap(); set_log_enabled(false); write_log("test", "should not appear"); set_log_enabled(true); diff --git a/crates/skill-tts/src/neutts.rs b/crates/skill-tts/src/neutts.rs index 8f715908..c9e448d1 100644 --- a/crates/skill-tts/src/neutts.rs +++ b/crates/skill-tts/src/neutts.rs @@ -126,7 +126,7 @@ pub fn apply_config(cfg: &crate::config::NeuttsConfig) { // When KittenTTS is also compiled, the `enabled` flag is the runtime switch // stored in `crate::NEUTTS_ENABLED`. Update it from here. - #[cfg(feature = "tts-kitten")] + #[cfg(tts_kitten_active)] crate::NEUTTS_ENABLED.store(cfg.enabled, Ordering::Relaxed); if cfg.enabled && was_ready { diff --git a/crates/skill-tty/Cargo.toml b/crates/skill-tty/Cargo.toml new file mode 100644 index 00000000..78fc624b --- /dev/null +++ b/crates/skill-tty/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "skill-tty" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-only" +description = "PTY proxy for transparent terminal session recording. Split out of skill-daemon so process-name kills (Tauri sidecar reload, kill-old-daemon-on-upgrade) do not sweep up active terminal sessions." + +[[bin]] +name = "skill-tty" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +libc = "0.2" +dirs = "6" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } + +[lints] +workspace = true diff --git a/crates/skill-tty/src/main.rs b/crates/skill-tty/src/main.rs new file mode 100644 index 00000000..1d55c73c --- /dev/null +++ b/crates/skill-tty/src/main.rs @@ -0,0 +1,507 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! PTY proxy for transparent terminal session recording. +//! +//! Spawns the user's shell on a new PTY pair and proxies stdin/stdout +//! between the controlling terminal and that PTY, while writing every byte +//! the shell produces to a log file. Unlike macOS's `script(1)`, this shim +//! correctly forwards SIGWINCH so TUI applications (vim, htop, claude code, +//! etc.) see resize events and re-render at the right size. +//! +//! Invoked as `skill-tty ` (log path optional). Lives in its own +//! binary — separate from `skill-daemon` — so blanket process-name kills +//! (Tauri sidecar reload, kill-old-daemon-on-upgrade) do not terminate +//! active recorded shells. + +#[cfg(not(unix))] +fn main() { + eprintln!("skill-tty: PTY recording is only supported on Unix platforms"); + std::process::exit(126); +} + +#[cfg(unix)] +fn main() { + let args: Vec = std::env::args().collect(); + let rest = &args[1..]; + + // Discoverability subcommands. Users land on `skill-tty` by inspecting + // their shell rc file or `which skill-tty`; --help/--status answer the + // first two questions they're likely to have without making them open + // the desktop UI. + if let Some(first) = rest.first().map(String::as_str) { + match first { + "--help" | "-h" | "help" => { + unix::print_help(); + return; + } + "--status" | "status" => { + std::process::exit(unix::print_status()); + } + "--version" | "-V" | "version" => { + println!("skill-tty {}", env!("CARGO_PKG_VERSION")); + return; + } + _ => {} + } + } + + // Exit 126 signals the shell hook that the PTY shim failed to start + // (not a tty, openpty failed, etc.) so the hook can fall through to a + // plain shell instead of closing the terminal. Any other exit code is + // the inner shell's own exit code and is forwarded as-is (`run` calls + // `std::process::exit` internally on success). + if let Err(e) = unix::run(rest) { + eprintln!("skill-tty: {e:#}"); + std::process::exit(126); + } +} + +#[cfg(unix)] +mod unix { + // Deliberately thin wrapper over libc PTY/signal/termios primitives. + // Every unsafe block invokes a libc function with arguments the Rust + // standard library or this file's own ownership invariants guarantee + // valid (FDs we just opened, pointers to local stack vars, etc.). + #![allow(clippy::undocumented_unsafe_blocks)] + + use std::ffi::CString; + use std::fs::OpenOptions; + use std::io::{BufWriter, Write}; + use std::os::fd::RawFd; + use std::sync::atomic::{AtomicBool, Ordering}; + + pub fn print_help() { + println!( + "skill-tty {} — NeuroSkill terminal-session recorder\n\ + \n\ + USAGE:\n \ + skill-tty [LOG_PATH] wrap the user's shell on a fresh PTY,\n \ + writing every byte to LOG_PATH (default:\n \ + ~/.skill/terminal-logs/-.log)\n \ + skill-tty --status print whether the *current* shell is\n \ + recorded, plus log path and session id\n \ + skill-tty --version print version and exit\n\ + \n\ + ENV VARS (read at startup):\n \ + NEUROSKILL_SKIP_RECORDING=1 the shell hook will NOT spawn\n \ + skill-tty for this terminal\n \ + NEUROSKILL_RECORDING=1 set by skill-tty inside the\n \ + wrapped shell; read by the hook\n \ + to prevent re-entry\n \ + NEUROSKILL_SESSION= session id, written to every\n \ + command tracked by the hook\n \ + SHELL which shell to spawn\n\ + \n\ + Logs and session metadata live under ~/.skill/. To uninstall the\n\ + shell hook, use the desktop app's Activity tab, or run the\n\ + daemon's POST /v1/activity/uninstall-shell-hook route. Removing\n\ + the daemon binary with --uninstall also strips all hook entries\n\ + from your rc files.", + env!("CARGO_PKG_VERSION"), + ); + } + + /// Print the current shell's recording state. Exits with 0 if the shell + /// invoking us is currently recorded, 1 otherwise. Suitable for use in + /// scripts: `skill-tty --status >/dev/null && echo "being recorded"`. + pub fn print_status() -> i32 { + let recording = std::env::var("NEUROSKILL_RECORDING").ok().filter(|v| !v.is_empty()); + let session = std::env::var("NEUROSKILL_SESSION").ok(); + let skip = std::env::var("NEUROSKILL_SKIP_RECORDING") + .ok() + .filter(|v| !v.is_empty()); + let home = dirs::home_dir(); + let log_dir = home.as_ref().map(|h| h.join(".skill").join("terminal-logs")); + + if recording.is_some() { + println!("recording: yes"); + if let Some(s) = session { + println!("session_id: {s}"); + } + if let Some(dir) = log_dir { + println!("log_dir: {}", dir.display()); + } + 0 + } else if skip.is_some() { + println!("recording: no (NEUROSKILL_SKIP_RECORDING set)"); + 1 + } else { + println!( + "recording: no\n\ + (this shell was not started under skill-tty; either no hook is installed,\n\ + or this terminal opted out, or the binary at the hook's path is missing)" + ); + 1 + } + } + + /// Set by the SIGWINCH handler; checked by the main I/O loop. + static SIGWINCH: AtomicBool = AtomicBool::new(false); + /// Set by SIGCHLD; main loop exits when set. + static SIGCHLD: AtomicBool = AtomicBool::new(false); + + extern "C" fn on_sigwinch(_: libc::c_int) { + SIGWINCH.store(true, Ordering::Relaxed); + } + extern "C" fn on_sigchld(_: libc::c_int) { + SIGCHLD.store(true, Ordering::Relaxed); + } + + /// Run the shim. Optional arg overrides the log path; otherwise we + /// pick one inside `~/.skill/terminal-logs/`. + pub fn run(args: &[String]) -> anyhow::Result<()> { + let log_path = match args.first() { + Some(p) => std::path::PathBuf::from(p), + None => default_log_path()?, + }; + rotate_logs(log_path.parent(), &log_path); + + // The session id is the log filename's stem (e.g. "20260426-104530-12345"). + // It uniquely identifies this shell instance and is exported so the + // preexec/precmd hooks can tag every command POST with it; the finalizer + // uses the same string when closing the `terminal_sessions` row. + let session_id = log_path + .file_stem() + .and_then(|s| s.to_str()) + .map(String::from) + .unwrap_or_default(); + + // BufWriter cuts syscalls per PTY-read from ~1 to ~0 (8 KB chunks fit + // most TUI redraw bursts). Final flush happens via Drop, but we also + // call .flush() explicitly before exit to surface I/O errors. + let log_file = OpenOptions::new().create(true).append(true).open(&log_path)?; + let mut log = BufWriter::with_capacity(8192, log_file); + + // Timing sidecar: 16 bytes per PTY-write batch — `(u64 offset_after_write_le, u64 unix_micros_le)`. + // Lets the finalizer binary-search any time T to its corresponding byte + // offset, so per-command output extraction is O(log N). + let idx_path = log_path.with_extension("idx"); + let idx_file = OpenOptions::new().create(true).append(true).open(&idx_path)?; + let mut idx = BufWriter::with_capacity(4096, idx_file); + let mut log_offset: u64 = 0; + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into()); + + let stdin_fd = libc::STDIN_FILENO; + let stdout_fd = libc::STDOUT_FILENO; + + // Initial winsize from the controlling tty. + let mut winsize: libc::winsize = unsafe { std::mem::zeroed() }; + unsafe { libc::ioctl(stdout_fd, libc::TIOCGWINSZ as _, &mut winsize) }; + if winsize.ws_col == 0 { + winsize.ws_col = 80; + } + if winsize.ws_row == 0 { + winsize.ws_row = 24; + } + + // Snapshot termios so we can restore it on exit. + let mut original_termios: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(stdin_fd, &mut original_termios) } != 0 { + return Err(anyhow::anyhow!("tcgetattr(stdin) failed: {}", last_err())); + } + + // openpty() returns a master/slave pair. Caller owns both FDs. + // libc declares the winsize parameter as *const on Linux and *mut on macOS, + // so we always pass &mut and silence the Linux-only "unnecessary mut" lint. + let mut master_fd: RawFd = -1; + let mut slave_fd: RawFd = -1; + let rc = unsafe { + #[allow(clippy::unnecessary_mut_passed)] + libc::openpty( + &mut master_fd, + &mut slave_fd, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut winsize, + ) + }; + if rc != 0 { + return Err(anyhow::anyhow!("openpty failed: {}", last_err())); + } + + let pid = unsafe { libc::fork() }; + if pid < 0 { + return Err(anyhow::anyhow!("fork failed: {}", last_err())); + } + + if pid == 0 { + // ── child: become session leader, attach slave as ctty, exec shell ── + unsafe { libc::close(master_fd) }; + unsafe { libc::setsid() }; + // TIOCSCTTY makes `slave_fd` the controlling terminal of the new session. + unsafe { libc::ioctl(slave_fd, libc::TIOCSCTTY as _, 0) }; + unsafe { + libc::dup2(slave_fd, libc::STDIN_FILENO); + libc::dup2(slave_fd, libc::STDOUT_FILENO); + libc::dup2(slave_fd, libc::STDERR_FILENO); + if slave_fd > 2 { + libc::close(slave_fd); + } + } + // Mark this shell as the wrapped one; the hook checks this env var. + unsafe { libc::setenv(c"NEUROSKILL_RECORDING".as_ptr(), c"1".as_ptr(), 1) }; + // Tag every command run in this shell with the same session id the + // finalizer will use when closing the terminal_sessions row. + if let Ok(sid_c) = CString::new(session_id.clone()) { + unsafe { libc::setenv(c"NEUROSKILL_SESSION".as_ptr(), sid_c.as_ptr(), 1) }; + } + + // Login-style argv0: prefix with `-` so the shell reads its rc files. + let basename = std::path::Path::new(&shell) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "shell".into()); + let argv0 = CString::new(format!("-{basename}")).unwrap(); + let path_c = CString::new(shell.clone()).unwrap(); + unsafe { + libc::execlp(path_c.as_ptr(), argv0.as_ptr(), std::ptr::null::()); + } + // execlp only returns on failure. + let _ = std::io::stderr().write_all(b"skill-tty: exec failed\n"); + unsafe { libc::_exit(127) }; + } + + // ── parent ── + unsafe { libc::close(slave_fd) }; + + // Emit OSC 2 immediately so the terminal tab/window title shows the cwd + // instead of our argv (which would otherwise expose the log file path). + // The inner shell's precmd will keep updating this on each prompt; this + // line just covers the brief gap between exec and the first prompt. + { + let cwd = std::env::current_dir().unwrap_or_default(); + let cwd_str = cwd.to_string_lossy(); + let home = std::env::var("HOME").unwrap_or_default(); + let display = if !home.is_empty() && cwd_str.starts_with(&home) { + format!("~{}", &cwd_str[home.len()..]) + } else { + cwd_str.into_owned() + }; + let osc = format!("\x1b]2;{display}\x07"); + let _ = write_all_fd(stdout_fd, osc.as_bytes()); + } + + // Restore original termios on every exit path. + struct TermiosGuard(libc::termios); + impl Drop for TermiosGuard { + fn drop(&mut self) { + unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, &self.0) }; + } + } + let _termios_guard = TermiosGuard(original_termios); + + // Put stdin in raw mode so every byte (incl. Ctrl-C, escape sequences, + // bracketed paste, mouse events) reaches the slave PTY untouched. + let mut raw = original_termios; + unsafe { libc::cfmakeraw(&mut raw) }; + unsafe { libc::tcsetattr(stdin_fd, libc::TCSAFLUSH, &raw) }; + + install_signal_handler(libc::SIGWINCH, on_sigwinch)?; + install_signal_handler(libc::SIGCHLD, on_sigchld)?; + + // Main I/O loop: select() on stdin and master_fd. Forward bytes both + // ways. On SIGWINCH, re-query the outer terminal's size and apply it + // to the master end of the PTY (which raises SIGWINCH inside the child). + let mut buf = [0u8; 8192]; + loop { + if SIGWINCH.swap(false, Ordering::Relaxed) { + let mut new_size: libc::winsize = unsafe { std::mem::zeroed() }; + if unsafe { libc::ioctl(stdout_fd, libc::TIOCGWINSZ as _, &mut new_size) } == 0 { + unsafe { libc::ioctl(master_fd, libc::TIOCSWINSZ as _, &new_size) }; + } + } + if SIGCHLD.load(Ordering::Relaxed) { + // Drain any remaining output from the master before quitting. + drain_master(master_fd, &mut buf, stdout_fd, &mut log, &mut idx, &mut log_offset); + break; + } + + let mut readfds: libc::fd_set = unsafe { std::mem::zeroed() }; + unsafe { + libc::FD_ZERO(&mut readfds); + libc::FD_SET(stdin_fd, &mut readfds); + libc::FD_SET(master_fd, &mut readfds); + } + let nfds = master_fd.max(stdin_fd) + 1; + let r = unsafe { + libc::select( + nfds, + &mut readfds, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if r < 0 { + let errno = std::io::Error::last_os_error().raw_os_error(); + if errno == Some(libc::EINTR) { + continue; + } + break; + } + + if unsafe { libc::FD_ISSET(stdin_fd, &readfds) } { + let n = unsafe { libc::read(stdin_fd, buf.as_mut_ptr().cast(), buf.len()) }; + if n <= 0 { + break; + } + let _ = write_all_fd(master_fd, &buf[..n as usize]); + } + if unsafe { libc::FD_ISSET(master_fd, &readfds) } { + let n = unsafe { libc::read(master_fd, buf.as_mut_ptr().cast(), buf.len()) }; + if n <= 0 { + break; + } + let bytes = &buf[..n as usize]; + log_offset += bytes.len() as u64; + let micros = unix_micros(); + let mut entry = [0u8; 16]; + entry[..8].copy_from_slice(&log_offset.to_le_bytes()); + entry[8..].copy_from_slice(µs.to_le_bytes()); + let _ = idx.write_all(&entry); + let _ = write_all_fd(stdout_fd, bytes); + let _ = log.write_all(bytes); + } + } + + let _ = log.flush(); + let _ = idx.flush(); + + let mut status: libc::c_int = 0; + unsafe { libc::waitpid(pid, &mut status, 0) }; + let exit_code = if libc::WIFEXITED(status) { + libc::WEXITSTATUS(status) + } else { + 1 + }; + drop(_termios_guard); // explicit so it runs before process::exit + std::process::exit(exit_code); + } + + fn install_signal_handler(sig: libc::c_int, handler: extern "C" fn(libc::c_int)) -> anyhow::Result<()> { + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = handler as usize; + action.sa_flags = libc::SA_RESTART; + unsafe { libc::sigemptyset(&mut action.sa_mask) }; + let rc = unsafe { libc::sigaction(sig, &action, std::ptr::null_mut()) }; + if rc != 0 { + return Err(anyhow::anyhow!("sigaction({sig}) failed: {}", last_err())); + } + Ok(()) + } + + fn write_all_fd(fd: RawFd, mut bytes: &[u8]) -> std::io::Result<()> { + while !bytes.is_empty() { + let n = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) }; + if n < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + continue; + } + return Err(err); + } + bytes = &bytes[n as usize..]; + } + Ok(()) + } + + fn drain_master( + master_fd: RawFd, + buf: &mut [u8], + stdout_fd: RawFd, + log: &mut W, + idx: &mut I, + log_offset: &mut u64, + ) { + // Make master non-blocking so we can drain whatever's pending. + let flags = unsafe { libc::fcntl(master_fd, libc::F_GETFL) }; + if flags >= 0 { + unsafe { libc::fcntl(master_fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; + } + loop { + let n = unsafe { libc::read(master_fd, buf.as_mut_ptr().cast(), buf.len()) }; + if n <= 0 { + break; + } + let bytes = &buf[..n as usize]; + let _ = write_all_fd(stdout_fd, bytes); + let _ = log.write_all(bytes); + *log_offset += bytes.len() as u64; + let micros = unix_micros(); + let mut entry = [0u8; 16]; + entry[..8].copy_from_slice(&log_offset.to_le_bytes()); + entry[8..].copy_from_slice(µs.to_le_bytes()); + let _ = idx.write_all(&entry); + } + } + + fn unix_micros() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros() as u64) + .unwrap_or(0) + } + + fn last_err() -> String { + std::io::Error::last_os_error().to_string() + } + + /// Default log path: `~/.skill/terminal-logs/-.log`. + fn default_log_path() -> anyhow::Result { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no $HOME"))?; + let dir = home.join(".skill").join("terminal-logs"); + std::fs::create_dir_all(&dir)?; + let ts = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let pid = std::process::id(); + Ok(dir.join(format!("{ts}-{pid}.log"))) + } + + /// Enforce a 100-file retention cap on terminal scratch logs. + /// + /// Keep this deliberately lightweight: `skill-tty` stays alive for the + /// entire shell session, so doing zstd compression here can leave allocator + /// workspaces charged to the long-lived shim. The daemon finalizer owns + /// heavy processing for completed sessions. + fn rotate_logs(dir: Option<&std::path::Path>, current_log: &std::path::Path) { + let Some(dir) = dir else { return }; + + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut all: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries + .filter_map(|e| e.ok()) + .filter(|e| { + let path = e.path(); + if path == current_log { + return false; + } + if path.extension().is_some_and(|e| e == "log") && pid_alive_for_log(&path) { + return false; + } + path.extension() + .and_then(|s| s.to_str()) + .is_some_and(|ext| ext == "log" || ext == "zst") + }) + .filter_map(|e| Some((e.path(), e.metadata().ok()?.modified().ok()?))) + .collect(); + all.sort_by_key(|(_, m)| std::cmp::Reverse(*m)); + for (path, _) in all.into_iter().skip(100) { + let _ = std::fs::remove_file(path); + } + } + + /// Filenames are `-.log` — extract the PID and check + /// whether that process still exists with `kill(pid, 0)`. + fn pid_alive_for_log(path: &std::path::Path) -> bool { + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + return false; + }; + let Some(pid_str) = stem.rsplit('-').next() else { + return false; + }; + let Ok(pid) = pid_str.parse::() else { + return false; + }; + if pid <= 0 { + return false; + } + unsafe { libc::kill(pid, 0) == 0 } + } +} diff --git a/deny.toml b/deny.toml index a7c85393..a9a5e35c 100644 --- a/deny.toml +++ b/deny.toml @@ -33,8 +33,9 @@ ignore = [ [licenses] # Third-party crates in this workspace include several with missing/legacy # license metadata. Keep SPDX allow-list enforcement, but avoid noisy -# warnings for unmatched allow entries. +# warnings for unmatched allow entries and feature-gated optional deps. unused-allowed-license = "allow" +unused-license-exception = "allow" allow = [ "MIT", "Apache-2.0", @@ -89,7 +90,9 @@ exceptions = [ { allow = ["GPL-3.0-only"], crate = "skill-skills" }, { allow = ["GPL-3.0-only"], crate = "skill-tools" }, { allow = ["GPL-3.0-only"], crate = "skill-tray" }, + { allow = ["GPL-3.0-only"], crate = "skill-neutts" }, { allow = ["GPL-3.0-only"], crate = "skill-tts" }, + { allow = ["GPL-3.0-only"], crate = "skill-tty" }, { allow = ["GPL-3.0-only"], crate = "skill-vision" }, # Third-party GPL crates pulled in by device drivers. # mw75 uses the deprecated "GPL-3.0" SPDX identifier (no license file), @@ -104,11 +107,78 @@ exceptions = [ { allow = ["GPL-3.0-only"], crate = "skill-location" }, { allow = ["GPL-3.0-only"], crate = "skill-iroh" }, { allow = ["GPL-3.0-only"], crate = "skill-lsl" }, - { allow = ["GPL-3.0-only"], crate = "sleepfm" }, { allow = ["GPL-3.0-only"], crate = "iroh-example-client" }, { allow = ["GPL-3.0-only"], crate = "iroh_test_client" }, { allow = ["GPL-3.0-only"], crate = "rlsl" }, { allow = ["GPL-3.0-only"], crate = "rlsl-iroh" }, + # RLX inference runtime — all crates are GPL-3.0-only. + { allow = ["GPL-3.0-only"], crate = "rlx" }, + { allow = ["GPL-3.0-only"], crate = "rlx-autodiff" }, + { allow = ["GPL-3.0-only"], crate = "rlx-bert" }, + { allow = ["GPL-3.0-only"], crate = "rlx-bonsai" }, + { allow = ["GPL-3.0-only"], crate = "rlx-cli" }, + { allow = ["GPL-3.0-only"], crate = "rlx-clinicalbert" }, + { allow = ["GPL-3.0-only"], crate = "rlx-cohere" }, + { allow = ["GPL-3.0-only"], crate = "rlx-compile" }, + { allow = ["GPL-3.0-only"], crate = "rlx-cpu" }, + { allow = ["GPL-3.0-only"], crate = "rlx-cuda" }, + { allow = ["GPL-3.0-only"], crate = "rlx-diamond" }, + { allow = ["GPL-3.0-only"], crate = "rlx-dinov2" }, + { allow = ["GPL-3.0-only"], crate = "rlx-driver" }, + { allow = ["GPL-3.0-only"], crate = "rlx-embed" }, + { allow = ["GPL-3.0-only"], crate = "rlx-fft" }, + { allow = ["GPL-3.0-only"], crate = "rlx-flow" }, + { allow = ["GPL-3.0-only"], crate = "rlx-flux2" }, + { allow = ["GPL-3.0-only"], crate = "rlx-fusion" }, + { allow = ["GPL-3.0-only"], crate = "rlx-gemma" }, + { allow = ["GPL-3.0-only"], crate = "rlx-gpu-kernels" }, + { allow = ["GPL-3.0-only"], crate = "rlx-gguf" }, + { allow = ["GPL-3.0-only"], crate = "rlx-granite" }, + { allow = ["GPL-3.0-only"], crate = "rlx-ir" }, + { allow = ["GPL-3.0-only"], crate = "rlx-kittentts" }, + { allow = ["GPL-3.0-only"], crate = "rlx-lfm" }, + { allow = ["GPL-3.0-only"], crate = "rlx-llada2" }, + { allow = ["GPL-3.0-only"], crate = "rlx-llama-base" }, + { allow = ["GPL-3.0-only"], crate = "rlx-llama32" }, + { allow = ["GPL-3.0-only"], crate = "rlx-locateanything" }, + { allow = ["GPL-3.0-only"], crate = "rlx-macros" }, + { allow = ["GPL-3.0-only"], crate = "rlx-metal" }, + { allow = ["GPL-3.0-only"], crate = "rlx-minicpm5" }, + { allow = ["GPL-3.0-only"], crate = "rlx-minimax" }, + { allow = ["GPL-3.0-only"], crate = "rlx-mistral" }, + { allow = ["GPL-3.0-only"], crate = "rlx-mlx" }, + { allow = ["GPL-3.0-only"], crate = "rlx-mlx-sys" }, + { allow = ["GPL-3.0-only"], crate = "rlx-models" }, + { allow = ["GPL-3.0-only"], crate = "rlx-models-core" }, + { allow = ["GPL-3.0-only"], crate = "rlx-nemotron" }, + { allow = ["GPL-3.0-only"], crate = "rlx-neutts" }, + { allow = ["GPL-3.0-only"], crate = "rlx-nomic" }, + { allow = ["GPL-3.0-only"], crate = "rlx-ocr" }, + { allow = ["GPL-3.0-only"], crate = "rlx-omnicoder" }, + { allow = ["GPL-3.0-only"], crate = "rlx-onnx-import" }, + { allow = ["GPL-3.0-only"], crate = "rlx-opt" }, + { allow = ["GPL-3.0-only"], crate = "rlx-phi" }, + { allow = ["GPL-3.0-only"], crate = "rlx-qwen3" }, + { allow = ["GPL-3.0-only"], crate = "rlx-qwen3-tts" }, + { allow = ["GPL-3.0-only"], crate = "rlx-qwen35" }, + { allow = ["GPL-3.0-only"], crate = "rlx-rocm" }, + { allow = ["GPL-3.0-only"], crate = "rlx-runtime" }, + { allow = ["GPL-3.0-only"], crate = "rlx-sam" }, + { allow = ["GPL-3.0-only"], crate = "rlx-sam-ir" }, + { allow = ["GPL-3.0-only"], crate = "rlx-sam2" }, + { allow = ["GPL-3.0-only"], crate = "rlx-sam3" }, + { allow = ["GPL-3.0-only"], crate = "rlx-ssm" }, + { allow = ["GPL-3.0-only"], crate = "rlx-tensor" }, + { allow = ["GPL-3.0-only"], crate = "rlx-text" }, + { allow = ["GPL-3.0-only"], crate = "rlx-umap" }, + { allow = ["GPL-3.0-only"], crate = "rlx-vad" }, + { allow = ["GPL-3.0-only"], crate = "rlx-vision" }, + { allow = ["GPL-3.0-only"], crate = "rlx-vjepa2" }, + { allow = ["GPL-3.0-only"], crate = "rlx-voxtral" }, + { allow = ["GPL-3.0-only"], crate = "rlx-voxtral-tts" }, + { allow = ["GPL-3.0-only"], crate = "rlx-wav2vec2-bert" }, + { allow = ["GPL-3.0-only"], crate = "rlx-wgpu" }, + { allow = ["GPL-3.0-only"], crate = "rlx-whisper" }, ] [[licenses.clarify]] @@ -202,15 +272,12 @@ skip = [ { crate = "base64@0.21.7" }, { crate = "bitflags@1.3.2" }, { crate = "winreg@0.55.0" }, - { crate = "winreg@0.56.0" }, - { crate = "wry@0.54.4" }, - { crate = "wry@0.55.0" }, { crate = "zip@2.4.2" }, { crate = "zip@4.6.1" }, { crate = "zip@7.2.0" }, { crate = "winnow@0.5.40" }, { crate = "winnow@0.7.15" }, - { crate = "winnow@1.0.2" }, + { crate = "winnow@1.0.3" }, { crate = "windows_aarch64_gnullvm@0.42.2" }, { crate = "windows_aarch64_gnullvm@0.48.5" }, { crate = "windows_aarch64_gnullvm@0.52.6" }, @@ -249,7 +316,6 @@ unknown-registry = "deny" unknown-git = "warn" allow-registry = ["https://github.com/rust-lang/crates.io-index"] allow-git = [ - "https://github.com/eugenehp/cubek.git", "https://github.com/eugenehp/btleplug.git", "https://github.com/eugenehp/gtk-rs-core.git", ] diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ec822d07..2181cb62 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -63,6 +63,57 @@ Environment toggles: - `unset LLAMA_PREBUILT_DIR` (force local llama.cpp build) - `SKILL_DAEMON_SERVICE_AUTOINSTALL=0` (disable daemon background-service auto-install for local testing) +## RLX (optional path dependency) + +Some crates (`skill-llm`, `skill-daemon-state`) can link the [RLX](https://github.com/MIT-RLX/rlx) runtime for experimental `llm-rlx` and `text-embeddings-rlx` features. Cargo resolves it as a **sibling checkout** at `../rlx/rlx` (see `[workspace.dependencies]` in the root `Cargo.toml`). + +### Directory layout + +``` +parent/ + skill/ ← this repository + rlx/ ← https://github.com/MIT-RLX/rlx.git + rlx/ ← the `rlx` crate (Cargo.toml lives here) +``` + +### Local setup + +Default checkout root is `/Users/Shared/rlx`. The setup script clones or updates that repo and symlinks `../rlx` next to `skill`: + +```bash +npm run setup:rlx +# or: bash scripts/ensure-rlx.sh +``` + +With [direnv](https://direnv.net/) enabled, `.envrc` runs `ensure-rlx.sh` when you enter the repo. + +**Override the checkout location** (gitignored): + +```bash +cp rlx.path.example rlx.path +# edit rlx.path — one line, absolute path to your rlx repo root +``` + +Or set `RLX_ROOT` for a single run: + +```bash +RLX_ROOT=~/src/rlx npm run setup:rlx +``` + +Optional env vars: + +| Variable | Default | Purpose | +|------------|---------------------------------|----------------------------------| +| `RLX_ROOT` | `/Users/Shared/rlx` | Local clone root | +| `RLX_URL` | `https://github.com/MIT-RLX/rlx.git` | Clone URL | +| `RLX_REF` | `main` | Branch to fetch/checkout | + +You do **not** need RLX for a normal dev build unless you enable `llm-rlx` / `text-embeddings-rlx` features. Cargo still needs `../rlx/rlx/Cargo.toml` to exist when those crates are in the workspace graph (CI always fetches RLX for that reason). + +### CI + +GitHub Actions use [`.github/actions/checkout-rlx`](../.github/actions/checkout-rlx), which runs `scripts/ensure-rlx.sh` with `GITHUB_ACTIONS=true` to clone `MIT-RLX/rlx` into `../rlx` on the runner (no symlink). The same step is wired into `ci.yml`, release workflows, and `pr-build.yml`. + ## Data health check ```bash diff --git a/extensions/browser b/extensions/browser index ab5d4226..6bd91f41 160000 --- a/extensions/browser +++ b/extensions/browser @@ -1 +1 @@ -Subproject commit ab5d42266f852c21e68acab8d78b20643b6198b0 +Subproject commit 6bd91f4119854f3f3b4d08545c1c0434bf8effee diff --git a/extensions/vscode b/extensions/vscode index 15e3109a..b0140762 160000 --- a/extensions/vscode +++ b/extensions/vscode @@ -1 +1 @@ -Subproject commit 15e3109a4dfc438fe96a2eefd65717e4956466c1 +Subproject commit b01407621c0d8c244db9f8fae8478fdc183b5f47 diff --git a/js b/js new file mode 100644 index 00000000..e69de29b diff --git a/neuroloop b/neuroloop index bf7514c7..6d1390e4 160000 --- a/neuroloop +++ b/neuroloop @@ -1 +1 @@ -Subproject commit bf7514c722d235f6c42e3cf7d35112e0865655ad +Subproject commit 6d1390e45bfb1b738a22e8f05569eb01ba2c9481 diff --git a/package-lock.json b/package-lock.json index 892e8f3b..ff5bfc83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "neuroskill", - "version": "0.0.129", + "version": "0.0.131-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neuroskill", - "version": "0.0.129", + "version": "0.0.131-rc.1", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index c68afc55..85a06c0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.129", + "version": "0.0.131-rc.6", "description": "", "type": "module", "scripts": { @@ -17,6 +17,7 @@ "test:all": "bash scripts/test-all.sh all", "test:fmt": "bash scripts/test-all.sh fmt", "test:lint": "bash scripts/test-all.sh lint", + "test:tiny-text": "bash scripts/test-all.sh tiny-text", "test:clippy": "bash scripts/test-all.sh clippy", "test:deny": "bash scripts/test-all.sh deny", "test:vitest": "bash scripts/test-all.sh vitest", @@ -36,13 +37,14 @@ "preview": "vite preview", "check:markdown-renderer": "node scripts/check-markdown-renderer.js", "check:daemon-invokes": "node scripts/check-daemon-invokes.js", + "check:settings-fonts": "node scripts/check-settings-font-sizes.js", "audit:daemon-routes": "node scripts/audit-daemon-routes.js", "verify:tauri:frontend": "node scripts/verify-tauri-frontend-structure.js", "check:i18n": "npx tsx scripts/audit-i18n.ts --check", "health": "node scripts/health.mjs", "check:i18n:locales": "node scripts/check-critical-i18n-locales.js", "check:i18n:critical": "npm run -s check:i18n:locales", - "check": "npm run -s check:markdown-renderer && npm run -s check:daemon-invokes && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check": "npm run -s check:markdown-renderer && npm run -s check:daemon-invokes && npm run -s check:settings-fonts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "npm run -s dev:guard && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --ignore src-tauri --watch", "tauri": "node scripts/tauri-build.js", "tauri:flamegraph": "node scripts/tauri-flamegraph.js", @@ -79,6 +81,7 @@ "setup": "bash scripts/setup-dev.sh", "setup:build-cache": "bash scripts/setup-build-cache.sh", "setup:llama-prebuilt": "bash scripts/download-llama-prebuilt.sh", + "setup:rlx": "bash scripts/ensure-rlx.sh", "compile:changelog": "node scripts/compile-changelog.js", "check:changelog": "node scripts/check-changelog-fragments.js", "check:changelog:fix": "node scripts/check-changelog-fragments.js --fix", @@ -97,6 +100,8 @@ "test:daemon-packaging:mac": "bash scripts/test-daemon-packaging.sh --os macos", "test:daemon-packaging:linux": "bash scripts/test-daemon-packaging.sh --os linux", "test:daemon-packaging:win": "powershell -ExecutionPolicy Bypass -File scripts/test-daemon-packaging.ps1", + "clean": "npm run -s clean:deps && npm run -s clean:rust", + "clean:deps": "node scripts/clean.js", "clean:rust": "node scripts/clean-rust.js", "lint": "npx biome check src/ scripts/", "lint:fix": "npx biome check --write src/ scripts/", diff --git a/rlx.path.example b/rlx.path.example new file mode 100644 index 00000000..74cb7f94 --- /dev/null +++ b/rlx.path.example @@ -0,0 +1,3 @@ +# Copy to rlx.path (gitignored) to override the local RLX checkout root. +# scripts/ensure-rlx.sh symlinks ../rlx -> this directory. +/Users/Shared/rlx diff --git a/scripts/aggregate-visual-report.mjs b/scripts/aggregate-visual-report.mjs new file mode 100644 index 00000000..04d5c459 --- /dev/null +++ b/scripts/aggregate-visual-report.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Merge per-test JSON files emitted by `src/tests/visual-layout.spec.ts` +// into a single report.json + summary.txt. +// +// Why a separate script: Playwright's `afterAll` runs per-worker, so any +// in-memory aggregation captures only one worker's slice. Each test +// instead writes its own JSON, and this script merges them after the +// suite is done. + +import * as fs from "node:fs"; +import * as path from "node:path"; + +const OUT = path.join(process.cwd(), "test-results", "visual-layout"); +const FINDINGS = path.join(OUT, "_findings"); + +if (!fs.existsSync(FINDINGS)) { + console.error(`No findings directory at ${FINDINGS}. Run the visual spec first.`); + process.exit(1); +} + +const files = fs.readdirSync(FINDINGS).filter((f) => f.endsWith(".json")); +const results = files.map((f) => JSON.parse(fs.readFileSync(path.join(FINDINGS, f), "utf8"))); + +const issueCountsByKind = {}; +for (const r of results) { + for (const i of r.issues) { + issueCountsByKind[i.kind] = (issueCountsByKind[i.kind] ?? 0) + 1; + } +} + +const sortedByIssues = [...results].sort((a, b) => b.issueCount - a.issueCount); + +const summary = { + total_combinations: results.length, + issue_counts_by_kind: issueCountsByKind, + combinations_with_issues: results.filter((r) => r.issueCount > 0).length, + combinations_clean: results.filter((r) => r.issueCount === 0).length, + worst_offenders: sortedByIssues.slice(0, 30).map((r) => ({ + route: r.route, + viewport: r.viewport, + scale: r.scale, + issues: r.issueCount, + screenshot: r.screenshot, + })), + results: sortedByIssues, +}; + +fs.writeFileSync(path.join(OUT, "report.json"), JSON.stringify(summary, null, 2)); + +const lines = []; +lines.push(`Visual layout audit — ${results.length} combinations`); +lines.push(` clean: ${summary.combinations_clean}`); +lines.push(` with issues: ${summary.combinations_with_issues}`); +lines.push(""); +lines.push("Issue counts by kind:"); +for (const [k, v] of Object.entries(issueCountsByKind)) { + lines.push(` ${k}: ${v}`); +} +lines.push(""); +lines.push("Top 30 offenders:"); +for (const o of summary.worst_offenders) { + if (o.issues === 0) break; + lines.push(` ${o.issues.toString().padStart(3)} ${o.route} ${o.viewport} ${o.scale}%`); +} + +// Per-route worst-case totals — useful triage column. +const perRoute = {}; +for (const r of results) { + if (!perRoute[r.route]) perRoute[r.route] = { total: 0, max: 0 }; + perRoute[r.route].total += r.issueCount; + perRoute[r.route].max = Math.max(perRoute[r.route].max, r.issueCount); +} +lines.push(""); +lines.push("Per route (sum / max single combo):"); +const routeOrder = Object.entries(perRoute).sort((a, b) => b[1].total - a[1].total); +for (const [route, stats] of routeOrder) { + lines.push(` ${route.padEnd(20)} total=${stats.total.toString().padStart(4)} worst=${stats.max}`); +} + +fs.writeFileSync(path.join(OUT, "summary.txt"), lines.join("\n")); +console.log(lines.join("\n")); diff --git a/scripts/assemble-macos-app.sh b/scripts/assemble-macos-app.sh index c8114396..0ecb376b 100755 --- a/scripts/assemble-macos-app.sh +++ b/scripts/assemble-macos-app.sh @@ -118,6 +118,68 @@ else exit 1 fi +# ── Copy skill-tty sidecar ──────────────────────────────────────────────── +# skill-tty is the PTY proxy that wraps the user's shell for terminal-session +# recording. Splitting it into its own binary (and its own .app wrapper) means +# blanket process-name kills against `skill-daemon` (Tauri sidecar reload, +# kill-old-daemon-on-upgrade) no longer terminate active recorded shells. +# It needs its own .app for parity with skill-daemon: independent CFBundleIdentifier +# (so TCC permissions are tracked separately), Info.plist with LSUIElement so +# Activity Monitor / Force Quit can identify it, and code signing. +TTY_SRC="$TAURI_DIR/target/$TARGET/release/skill-tty" +TTY_APP="" +if [[ -f "$TTY_SRC" ]]; then + TTY_APP="$MACOS_DIR/skill-tty.app" + TTY_CONTENTS="$TTY_APP/Contents" + TTY_MACOS="$TTY_CONTENTS/MacOS" + TTY_RES="$TTY_CONTENTS/Resources" + mkdir -p "$TTY_MACOS" "$TTY_RES" + + cp "$TTY_SRC" "$TTY_MACOS/skill-tty" + chmod +x "$TTY_MACOS/skill-tty" + + # skill-tty has no heavy dylib deps (libc / dirs / chrono / zstd are all + # statically linked or system frameworks), so we don't need a Frameworks dir. + + if [[ -f "$TAURI_DIR/icons/icon.icns" ]]; then + cp "$TAURI_DIR/icons/icon.icns" "$TTY_RES/icon.icns" + fi + + cat > "$TTY_CONTENTS/Info.plist" << TTYPLIST + + + + + CFBundleExecutable + skill-tty + CFBundleIdentifier + com.neuroskill.skill-tty + CFBundleName + Skill TTY + CFBundleDisplayName + Skill TTY + CFBundleVersion + $VERSION + CFBundleShortVersionString + $VERSION + CFBundlePackageType + APPL + CFBundleIconFile + icon + LSBackgroundOnly + + LSUIElement + + + +TTYPLIST + + echo " ✓ skill-tty.app" +else + echo "WARNING: missing skill-tty sidecar: $TTY_SRC" >&2 + echo "Terminal session recording will fall back to skill-daemon's in-process shim." >&2 +fi + # ── Info.plist ──────────────────────────────────────────────────────────── # Start from the project's custom Info.plist and inject required CFBundle keys CUSTOM_PLIST="$TAURI_DIR/Info.plist" @@ -233,10 +295,41 @@ if [[ -n "$FRONTEND_DIR" && -d "$FRONTEND_DIR" ]]; then fi -# ── Entitlements & codesign ─────────────────────────────────────────────── +# ── Entitlements & codesign (inside-out) ────────────────────────────────── +# Apple recommends signing nested bundles individually before the outer one +# rather than relying on `--deep`, which is deprecated and silently mis-signs +# nested code in some cases. We sign skill-daemon.app and skill-tty.app first +# (with the daemon's entitlements — the daemon needs Bluetooth/networking; +# skill-tty inherits the same identity but doesn't need special entitlements, +# we just want a valid signature so notarization passes). SIGN_ID="${APPLE_SIGNING_IDENTITY:--}" ENTITLEMENTS="$TAURI_DIR/entitlements.plist" -SIGN_ARGS=(--force --deep --sign "$SIGN_ID" --options runtime) + +inner_sign_args=(--force --sign "$SIGN_ID" --options runtime --timestamp) +if [[ -f "$ENTITLEMENTS" ]]; then + inner_sign_args+=(--entitlements "$ENTITLEMENTS") +fi + +# Some flags are unsupported with the ad-hoc identity ("-"). +if [[ "$SIGN_ID" == "-" ]]; then + inner_sign_args=(--force --sign "-") +fi + +if [[ -d "$DAEMON_APP" ]]; then + codesign "${inner_sign_args[@]}" "$DAEMON_APP" + echo " ✓ codesigned skill-daemon.app" +fi +if [[ -d "$TTY_APP" ]]; then + codesign "${inner_sign_args[@]}" "$TTY_APP" + echo " ✓ codesigned skill-tty.app" +fi + +# Outer .app — same args as before, minus --deep (nested bundles are already +# signed). Keep entitlements for the main app so its capabilities are honoured. +SIGN_ARGS=(--force --sign "$SIGN_ID" --options runtime) +if [[ "$SIGN_ID" != "-" ]]; then + SIGN_ARGS+=(--timestamp) +fi if [[ -f "$ENTITLEMENTS" ]]; then SIGN_ARGS+=(--entitlements "$ENTITLEMENTS") fi @@ -248,6 +341,15 @@ else echo " ✓ codesigned ($SIGN_ID)" fi +# Verify the result so a botched sign fails the build instead of breaking +# silently at notarization or first launch. +if ! codesign --verify --deep --strict --verbose=2 "$APP_DIR" >/dev/null 2>&1; then + echo "ERROR: codesign --verify failed for $APP_DIR" >&2 + codesign --verify --deep --strict --verbose=2 "$APP_DIR" >&2 || true + exit 1 +fi +echo " ✓ codesign --verify passed" + echo "" echo "✓ $APP_DIR" echo "" diff --git a/scripts/check-daemon-invokes.js b/scripts/check-daemon-invokes.js index 45795696..362b6db3 100644 --- a/scripts/check-daemon-invokes.js +++ b/scripts/check-daemon-invokes.js @@ -240,7 +240,9 @@ const DAEMON_OWNED_COMMANDS = new Set([ "get_hook_log_count", "get_hook_statuses", "get_hooks", + "get_label_index_backend", "get_label_embedding_status", + "get_label_index_stats", "get_llm_catalog", "get_llm_downloads", "get_llm_logs", @@ -281,6 +283,7 @@ const DAEMON_OWNED_COMMANDS = new Set([ "resume_llm_download", "list_search_devices", "rebuild_label_index", + "benchmark_label_index", "search_corpus_stats", "search_labels_by_text", "search_screenshots_by_text", @@ -294,6 +297,7 @@ const DAEMON_OWNED_COMMANDS = new Set([ "set_filter_config", "set_goal_notified_date", "set_hooks", + "set_label_index_backend", "set_reembed_config", "set_neutts_config", "set_screenshot_config", diff --git a/scripts/check-settings-font-sizes.baseline.json b/scripts/check-settings-font-sizes.baseline.json new file mode 100644 index 00000000..48c8059b --- /dev/null +++ b/scripts/check-settings-font-sizes.baseline.json @@ -0,0 +1,31 @@ +{ + "ActivityTab.svelte": 66, + "AppearanceTab.svelte": 1, + "CalibrationTab.svelte": 1, + "ClientsTab.svelte": 15, + "DevicesTab.svelte": 6, + "EegModelTab.svelte": 1, + "EmbeddingsTab.svelte": 0, + "ExgTab.svelte": 2, + "ExtensionsTab.svelte": 12, + "GoalsTab.svelte": 4, + "HooksTab.svelte": 18, + "LlmTab.svelte": 0, + "LslTab.svelte": 1, + "PermissionsTab.svelte": 7, + "PvtPanel.svelte": 6, + "ScreenshotsTab.svelte": 0, + "SettingsTab.svelte": 1, + "ShortcutsTab.svelte": 0, + "SleepTab.svelte": 2, + "TerminalSessionsCard.svelte": 23, + "TerminalTab.svelte": 8, + "TlxForm.svelte": 6, + "TokensTab.svelte": 0, + "ToolsTab.svelte": 0, + "TtsTab.svelte": 2, + "UmapTab.svelte": 0, + "UpdatesTab.svelte": 5, + "ValidationTab.svelte": 36, + "VirtualEegTab.svelte": 0 +} diff --git a/scripts/check-settings-font-sizes.js b/scripts/check-settings-font-sizes.js new file mode 100644 index 00000000..dad567bd --- /dev/null +++ b/scripts/check-settings-font-sizes.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// +// check-settings-font-sizes.js — guard against font-size drift in +// src/lib/settings/*.svelte. +// +// Settings tabs should size text via the `text-ui-{xs,sm,base,md,lg,xl}` scale +// only. Bare Tailwind sizes (`text-xs`, `text-base`, `text-lg`, `text-2xl`, +// `text-[10px]`, …) cause visual inconsistency between tabs and were the +// motivation for introducing the `text-ui-*` system. +// +// We don't fix the existing 200+ pre-existing violations — that's a separate +// cleanup pass. Instead this script snapshots the current per-file violation +// counts in `check-settings-font-sizes.baseline.json` and fails if any file's +// count grows or a previously-clean file gains a violation. New files must +// start at zero. +// +// Refresh the baseline after an intentional cleanup with: +// node scripts/check-settings-font-sizes.js --update +// +// Allowed: text-ui-xs | text-ui-sm | text-ui-base | text-ui-md | text-ui-lg | text-ui-xl +// Violation: text-(xs|sm|base|lg|xl|xl|[]) + +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const SETTINGS_DIR = path.resolve("src/lib/settings"); +const BASELINE_PATH = path.resolve("scripts/check-settings-font-sizes.baseline.json"); + +// `(?!ui-)` skips `text-ui-*`. Trailing `\b` works for word-char endings; +// the alternation includes `\[…\]` for arbitrary values. +const VIOLATION_RE = /text-(?!ui-)((?:\d?xl|xs|sm|base|lg|xl)\b|\[[^\]]+\])/g; + +function listSettingsTabs() { + return readdirSync(SETTINGS_DIR) + .filter((f) => f.endsWith(".svelte")) + .sort(); +} + +function countViolations(filePath) { + const src = readFileSync(filePath, "utf8"); + return (src.match(VIOLATION_RE) ?? []).length; +} + +function currentCounts() { + const out = {}; + for (const file of listSettingsTabs()) { + out[file] = countViolations(path.join(SETTINGS_DIR, file)); + } + return out; +} + +function loadBaseline() { + try { + return JSON.parse(readFileSync(BASELINE_PATH, "utf8")); + } catch { + return null; + } +} + +const update = process.argv.includes("--update"); +const counts = currentCounts(); + +if (update) { + writeFileSync(BASELINE_PATH, `${JSON.stringify(counts, null, 2)}\n`); + console.log(`✅ baseline written: ${BASELINE_PATH}`); + process.exit(0); +} + +const baseline = loadBaseline(); +if (!baseline) { + writeFileSync(BASELINE_PATH, `${JSON.stringify(counts, null, 2)}\n`); + console.log(`📌 baseline initialised: ${BASELINE_PATH}`); + process.exit(0); +} + +const regressions = []; +for (const [file, count] of Object.entries(counts)) { + const prev = baseline[file]; + if (prev === undefined && count > 0) { + regressions.push(` ✗ ${file}: new file with ${count} non-ui- text size(s)`); + } else if (prev !== undefined && count > prev) { + regressions.push(` ✗ ${file}: ${prev} → ${count} non-ui- text size(s)`); + } +} + +if (regressions.length > 0) { + console.error("❌ settings tab font-size regressions:"); + console.error(regressions.join("\n")); + console.error( + "\nUse the `text-ui-{xs,sm,base,md,lg,xl}` scale instead of bare Tailwind sizes.\n" + + "If the change is intentional (e.g. removed an outlier), refresh the baseline:\n" + + " node scripts/check-settings-font-sizes.js --update", + ); + process.exit(1); +} + +// Surface drops too — they're not failures, but worth knowing. +const drops = []; +for (const [file, prev] of Object.entries(baseline)) { + const cur = counts[file]; + if (cur === undefined) continue; + if (cur < prev) drops.push(` ✓ ${file}: ${prev} → ${cur}`); +} +if (drops.length > 0) { + console.log("ℹ︎ settings tab font-size violations decreased — refresh baseline to lock in:"); + console.log(drops.join("\n")); + console.log(" node scripts/check-settings-font-sizes.js --update"); +} + +console.log("✅ no settings tab font-size regressions"); diff --git a/scripts/ci.mjs b/scripts/ci.mjs index 028a7833..ca1afd56 100644 --- a/scripts/ci.mjs +++ b/scripts/ci.mjs @@ -29,7 +29,7 @@ import { createWriteStream } from "fs"; import https from "https"; import http from "http"; -const LLAMA_PREBUILT_TAG = "0.2.46"; +const LLAMA_PREBUILT_TAG = "v0.3.0"; // ── Globals ────────────────────────────────────────────────────────────────── @@ -323,7 +323,7 @@ function ensureRcLatestRelease() { ], { check: true }); } -function cmdDiscordNotify(args) { +async function cmdDiscordNotify(args) { const webhook = process.env.DISCORD_WEBHOOK_URL; if (!webhook) { console.log("⚠ DISCORD_WEBHOOK_URL not set, skipping."); @@ -359,8 +359,13 @@ function cmdDiscordNotify(args) { }); try { - const r = spawnSync("curl", ["-sf", "-X", "POST", webhook, "-H", "Content-Type: application/json", "-d", payload], { stdio: "pipe", encoding: "utf8" }); - if (r.status !== 0) throw new Error(`curl exited ${r.status}`); + // Use built-in fetch (Node 18+) to avoid platform-specific curl quoting issues. + const r = await fetch(webhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); } catch { console.log("⚠ Discord notification failed (non-fatal)."); } diff --git a/scripts/clean.js b/scripts/clean.js new file mode 100644 index 00000000..4c0e00ca --- /dev/null +++ b/scripts/clean.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Cross-platform clean script — removes gitignored build artifacts and +// node_modules from all sub-packages. Reports disk space reclaimed. +// Rust target/ is handled separately by clean:rust. + +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const DIRS = [ + // npm / JS build artifacts + "node_modules", + "neuroloop/node_modules", + "neuroskill/node_modules", + "extensions/browser/node_modules", + "extensions/vscode/node_modules", + // VS Code test runner binary (~640 MB) + "extensions/vscode/.vscode-test", + // Xcode build artifacts + "extensions/widgets/.build", + // Svelte / Vite build cache + ".svelte-kit", + "build", + // Sidecar binaries (re-built or downloaded by tauri build / setup scripts) + "src-tauri/binaries", +]; + +function dirSize(dir) { + let total = 0; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return 0; + } + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) total += dirSize(p); + else { + try { + total += fs.statSync(p).size; + } catch {} + } + } + return total; +} + +function fmt(bytes) { + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`; + if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} + +function removeDir(abs) { + try { + fs.rmSync(abs, { recursive: true, force: true, maxRetries: 3, retryDelay: 500 }); + } catch { + execSync(`rm -rf ${JSON.stringify(abs)}`, { stdio: "inherit" }); + } +} + +let totalBytes = 0; +for (const rel of DIRS) { + const abs = path.join(root, rel); + if (!fs.existsSync(abs)) continue; + const bytes = dirSize(abs); + totalBytes += bytes; + process.stdout.write(` removing ${rel} (${fmt(bytes)})…`); + removeDir(abs); + console.log(" done"); +} + +if (totalBytes === 0) { + console.log("nothing to clean"); +} else { + console.log(`\ncleaned ${fmt(totalBytes)}`); +} diff --git a/scripts/create-macos-dmg.sh b/scripts/create-macos-dmg.sh index d0a0a732..4207d4bc 100755 --- a/scripts/create-macos-dmg.sh +++ b/scripts/create-macos-dmg.sh @@ -58,9 +58,14 @@ if [[ "$VERSION" != "$VERSION_CHECK" ]]; then echo " Using config version: $VERSION" fi -# ── Sign the .app ───────────────────────────────────────────────────────── +# ── Re-sign the .app (outer bundle only) ───────────────────────────────── +# Inner bundles (skill-daemon.app, skill-tty.app) were already signed +# individually with their entitlements by assemble-macos-app.sh (inside-out). +# Using --deep here would re-sign them WITHOUT entitlements (--deep applies +# entitlements only to the outermost bundle), stripping the daemon's +# Bluetooth/networking capabilities. Sign outer only, no --deep. echo " Signing .app with identity: $SIGN_ID" -SIGN_ARGS=(--deep --force --verify --verbose --sign "$SIGN_ID") +SIGN_ARGS=(--force --verify --verbose --sign "$SIGN_ID") if [[ "$SIGN_ID" != "-" ]]; then SIGN_ARGS+=(--timestamp --options runtime) fi diff --git a/scripts/create-windows-nsis.ps1 b/scripts/create-windows-nsis.ps1 index 84de391c..b89dba22 100644 --- a/scripts/create-windows-nsis.ps1 +++ b/scripts/create-windows-nsis.ps1 @@ -56,6 +56,21 @@ $Conf = Get-Content (Join-Path $TauriDir "tauri.conf.json") -Raw | ConvertFrom-J $ProductName = $Conf.productName $ProductDisplayName = if ($ProductName.EndsWith("™")) { $ProductName } else { "$ProductName™" } $Version = $Conf.version + +# NSIS's VIProductVersion requires strict 4-segment numeric format X.X.X.X +# (Win32 VS_FIXEDFILEINFO). User-facing ProductVersion/FileVersion strings +# accept any text, but VIProductVersion does not — it rejects "-rc.N" suffixes. +# Map the SemVer string to a numeric 4-tuple: +# "0.0.130" -> "0.0.130.0" +# "0.0.130-rc.2" -> "0.0.130.2" (use RC number as fourth segment) +# "0.0.130-beta.7" -> "0.0.130.7" +if ($Version -match '^(\d+\.\d+\.\d+)(?:-[A-Za-z]+\.(\d+))?') { + $vibase = $Matches[1] + $vibuild = if ($Matches[2]) { $Matches[2] } else { "0" } + $VIVersion = "$vibase.$vibuild" +} else { + $VIVersion = "0.0.0.0" +} $Identifier = $Conf.identifier $BinaryName = "skill.exe" $TargetReleaseDir = Join-Path $TauriDir "target/$Target/release" @@ -176,28 +191,34 @@ try { Write-Host " [ok] icon.ico" } - # ── Bundle ONNX Runtime DLL ────────────────────────────────────────────── - # ort-sys downloads onnxruntime.dll into Cargo's OUT_DIR at build time. - # The binary links against it dynamically; without bundling it the - # installer will deploy a broken binary that fails to start on users' - # machines. Windows finds DLLs in the same directory as the .exe, so - # placing it in $INSTDIR alongside skill.exe is sufficient — no PATH - # change or rpath patch needed. - $OrtDll = Get-ChildItem -Path $ReleaseDir -Recurse -Filter "onnxruntime.dll" ` - -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -like "*\build\*" -or $_.FullName -like "*ort-sys*" } | - Select-Object -First 1 - if (-not $OrtDll) { - # Broader fallback: any onnxruntime.dll anywhere under the release target - $OrtDll = Get-ChildItem -Path $ReleaseDir -Recurse -Filter "onnxruntime.dll" ` - -ErrorAction SilentlyContinue | - Select-Object -First 1 - } - if ($OrtDll) { - Copy-Item $OrtDll.FullName (Join-Path $Staging "onnxruntime.dll") -Force - Write-Host " [ok] onnxruntime.dll (from $($OrtDll.FullName))" + # ── ONNX Runtime — not bundled on Windows ──────────────────────────────── + # KittenTTS (the only `ort` consumer) is gated off for Windows in + # src-tauri/Cargo.toml + crates/skill-tts/Cargo.toml, so there is no + # onnxruntime.dll to ship. If a Windows TTS backend that needs ORT is + # reintroduced, restore the bundling block. + + # ── Bundle OpenBLAS DLL ────────────────────────────────────────────────── + # rlx-cpu's `blas` default feature links against openblas; turbovec on + # Linux/macOS does the same. The upstream OpenBLAS 0.3.30 Windows DLL + # is statically linked against the MinGW runtime so no extra DLLs are + # needed alongside it. + $OpenBlasDll = $null + if ($env:OPENBLAS_DLL -and (Test-Path $env:OPENBLAS_DLL)) { + $OpenBlasDll = Get-Item $env:OPENBLAS_DLL + } else { + $OpenBlasDll = Get-ChildItem -Path $ReleaseDir -Recurse -Filter "openblas.dll" ` + -ErrorAction SilentlyContinue | + Select-Object -First 1 + } + if (-not $OpenBlasDll -and $env:OPENBLAS_DIR) { + $alt = Join-Path $env:OPENBLAS_DIR "bin\openblas.dll" + if (Test-Path $alt) { $OpenBlasDll = Get-Item $alt } + } + if ($OpenBlasDll) { + Copy-Item $OpenBlasDll.FullName (Join-Path $Staging "openblas.dll") -Force + Write-Host " [ok] openblas.dll (from $($OpenBlasDll.FullName))" } else { - Write-Warning " onnxruntime.dll not found in build output — binary may fail to start" + Write-Warning " openblas.dll not found — turboquant / rlx-cpu BLAS paths will fail at runtime" } # ── Bundle VC++ CRT DLLs (app-local deployment) ───────────────────────── @@ -433,9 +454,9 @@ print(' [ok] installer images generated') $installFiles += " File `"$doc`"" } } - # Bundle ONNX Runtime DLL if present (downloaded by ort-sys at build time) - if (Test-Path (Join-Path $Staging "onnxruntime.dll")) { - $installFiles += ' File "onnxruntime.dll"' + # Bundle OpenBLAS DLL if present (rlx-cpu / turbovec runtime dependency) + if (Test-Path (Join-Path $Staging "openblas.dll")) { + $installFiles += ' File "openblas.dll"' } # Bundle VC++ CRT DLLs (app-local deployment) @@ -449,7 +470,7 @@ print(' [ok] installer images generated') $uninstallFiles += ' Delete "$INSTDIR\skill.exe"' $uninstallFiles += ' Delete "$INSTDIR\skill-daemon.exe"' $uninstallFiles += ' Delete "$INSTDIR\icon.ico"' - $uninstallFiles += ' Delete "$INSTDIR\onnxruntime.dll"' + $uninstallFiles += ' Delete "$INSTDIR\openblas.dll"' foreach ($doc in @("README.md", "CHANGELOG.md", "LICENSE")) { $uninstallFiles += " Delete `"`$INSTDIR\$doc`"" } @@ -531,7 +552,7 @@ $imageDirectives !insertmacro MUI_LANGUAGE "English" ; ── Version info ──────────────────────────────────────────────────────── -VIProductVersion "$Version.0" +VIProductVersion "$VIVersion" VIAddVersionKey "ProductName" "$ProductDisplayName" VIAddVersionKey "ProductVersion" "$Version" VIAddVersionKey "FileVersion" "$Version" diff --git a/scripts/daemon.ts b/scripts/daemon.ts index 4b330377..825bb534 100644 --- a/scripts/daemon.ts +++ b/scripts/daemon.ts @@ -288,13 +288,19 @@ ${B}Examples:${R} process.exit(1); } - // Codesign on macOS + // Codesign on macOS — sign daemon and the sibling skill-tty if present. if (opts.sign && platform() === "darwin") { try { const ids = execSync("security find-identity -v -p codesigning", { encoding: "utf8" }); if (ids.includes("NeuroSkill Dev")) { execSync(`codesign -s "NeuroSkill Dev" -f "${bin}"`, { stdio: "ignore" }); - ok("codesigned"); + const tty = bin.replace(/skill-daemon$/, "skill-tty"); + if (tty !== bin && existsSync(tty)) { + execSync(`codesign -s "NeuroSkill Dev" -f "${tty}"`, { stdio: "ignore" }); + ok("codesigned daemon + tty"); + } else { + ok("codesigned"); + } } } catch { /* non-fatal */ diff --git a/scripts/ensure-rlx.sh b/scripts/ensure-rlx.sh new file mode 100755 index 00000000..1515419d --- /dev/null +++ b/scripts/ensure-rlx.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Ensure the sibling ../rlx checkout exists for Cargo path deps (../rlx/rlx). +# +# CI: clones https://github.com/MIT-RLX/rlx.git into ../rlx +# Local: symlink ../rlx -> RLX_ROOT (default /Users/Shared/rlx) +# +# Override: +# RLX_ROOT=/path/to/rlx ./scripts/ensure-rlx.sh +# echo /path/to/rlx > rlx.path # gitignored; see rlx.path.example + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LINK="${REPO_ROOT}/../rlx" +RLX_URL="${RLX_URL:-https://github.com/MIT-RLX/rlx.git}" +RLX_REF="${RLX_REF:-main}" + +if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + RLX_ROOT="${RLX_ROOT:-${LINK}}" +elif [[ -f "${REPO_ROOT}/rlx.path" ]]; then + RLX_ROOT="$(tr -d '[:space:]' < "${REPO_ROOT}/rlx.path")" +else + RLX_ROOT="${RLX_ROOT:-/Users/Shared/rlx}" +fi + +if [[ -z "${RLX_ROOT}" ]]; then + echo "ensure-rlx: RLX_ROOT is empty" >&2 + exit 1 +fi + +manifest_ok() { + [[ -f "$1/rlx/Cargo.toml" ]] +} + +ensure_checkout() { + local root="$1" + if manifest_ok "${root}"; then + if [[ -d "${root}/.git" ]]; then + echo "ensure-rlx: updating ${root} (${RLX_REF})" + git -C "${root}" fetch --depth 1 origin "${RLX_REF}" + git -C "${root}" checkout -B "${RLX_REF}" "origin/${RLX_REF}" 2>/dev/null \ + || git -C "${root}" checkout FETCH_HEAD + fi + return 0 + fi + if [[ -e "${root}" ]]; then + echo "ensure-rlx: ${root} exists but rlx/rlx/Cargo.toml is missing" >&2 + return 1 + fi + echo "ensure-rlx: cloning ${RLX_URL} -> ${root} (${RLX_REF})" + git clone --depth 1 --branch "${RLX_REF}" "${RLX_URL}" "${root}" + manifest_ok "${root}" +} + +# CI: real checkout at ../rlx (no symlink). +if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + ensure_checkout "${RLX_ROOT}" + echo "ensure-rlx: CI — ${RLX_ROOT} ready" + exit 0 +fi + +# Local: keep RLX_ROOT (e.g. /Users/Shared/rlx) and symlink ../rlx -> it. +ensure_checkout "${RLX_ROOT}" + +if manifest_ok "${LINK}"; then + if [[ "$(cd "${LINK}" && pwd -P)" == "$(cd "${RLX_ROOT}" && pwd -P)" ]]; then + echo "ensure-rlx: ${LINK} -> ${RLX_ROOT}" + exit 0 + fi + if [[ -L "${LINK}" ]]; then + rm "${LINK}" + else + echo "ensure-rlx: ${LINK} exists and is not the RLX checkout (set RLX_ROOT or rlx.path)" >&2 + exit 1 + fi +fi + +mkdir -p "$(dirname "${LINK}")" +ln -sfn "${RLX_ROOT}" "${LINK}" +echo "ensure-rlx: linked ${LINK} -> ${RLX_ROOT}" diff --git a/scripts/install-openblas-windows.ps1 b/scripts/install-openblas-windows.ps1 new file mode 100644 index 00000000..b8478e8b --- /dev/null +++ b/scripts/install-openblas-windows.ps1 @@ -0,0 +1,135 @@ +# install-openblas-windows.ps1 +# +# Installs OpenBLAS (with LAPACK) for Windows MSVC builds and exports +# OPENBLAS_DIR + OPENBLAS_LIB_DIR so rlx-cpu, openblas-src, and any other +# `cargo:rustc-link-lib=openblas` consumer can find openblas.lib at link +# time and openblas.dll at runtime. +# +# Why upstream OpenBLAS rather than vcpkg: +# vcpkg openblas:x64-windows omits LAPACK from openblas.dll. rlx-cpu's +# `blas` feature calls dgesv_ / sgesv_ (LAPACK linear-system solvers), so +# linking against the vcpkg DLL fails with LNK2019. The upstream +# OpenBLAS Windows release archive bundles LAPACK into libopenblas.dll +# along with the import lib, so a single download covers BLAS + LAPACK. +# +# Why the rename step: +# Upstream ships `libopenblas.lib` / `libopenblas.dll` (MinGW-style +# naming). The Rust ecosystem (rlx-cpu, openblas-src, blas-src, etc.) +# emits `cargo:rustc-link-lib=openblas`, which MSVC's link.exe resolves +# to `openblas.lib`. We create that name alongside the originals so both +# conventions work. +# +# Outputs: +# $env:OPENBLAS_DIR — root containing lib\openblas.lib + bin\openblas.dll +# $env:OPENBLAS_LIB_DIR — convenience alias (== $OPENBLAS_DIR\lib) +# $env:OPENBLAS_DLL — absolute path to openblas.dll (for staging) +# $env:OPENBLAS_RUNTIME_DLLS — semicolon-separated list of MinGW runtime +# DLLs (libgcc_s_seh-1.dll, libgfortran-5.dll, libquadmath-0.dll, +# libwinpthread-1.dll) that need to ship alongside openblas.dll. +# +# If $env:GITHUB_ENV / $env:GITHUB_PATH are set, the values are also +# propagated to subsequent GitHub Actions steps. + +$ErrorActionPreference = "Stop" + +function Step ($msg) { Write-Host "`n>> $msg" -ForegroundColor Blue } +function Ok ($msg) { Write-Host " $msg" -ForegroundColor Green } +function Warn ($msg) { Write-Host " $msg" -ForegroundColor Yellow } +function Die ($msg) { Write-Host "`nERROR: $msg" -ForegroundColor Red; exit 1 } + +function Export-Env ($name, $value) { + Set-Item -Path "Env:$name" -Value $value + if ($env:GITHUB_ENV) { + "$name=$value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } +} + +function Export-Path ($dir) { + if (-not (Test-Path $dir)) { return } + $env:PATH = "$dir;$env:PATH" + if ($env:GITHUB_PATH) { + $dir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + } +} + +$OpenBlasVersion = if ($env:OPENBLAS_VERSION) { $env:OPENBLAS_VERSION } else { "0.3.30" } +$Root = if ($env:OPENBLAS_INSTALL_DIR) { + $env:OPENBLAS_INSTALL_DIR +} else { + "C:\OpenBLAS" +} + +function Validate-OpenBLAS ($root) { + $lib = Join-Path $root "lib\openblas.lib" + $dll = Join-Path $root "bin\openblas.dll" + if ((Test-Path $lib) -and (Test-Path $dll)) { + return [pscustomobject]@{ + Lib = $lib + Dll = $dll + Bin = (Join-Path $root "bin") + } + } + return $null +} + +# 1. Skip download if already installed and complete. +$found = Validate-OpenBLAS $Root +if (-not $found) { + Step "Download OpenBLAS $OpenBlasVersion (with LAPACK) -> $Root" + $zipName = "OpenBLAS-$OpenBlasVersion-x64.zip" + $url = "https://github.com/OpenMathLib/OpenBLAS/releases/download/v$OpenBlasVersion/$zipName" + $tempZip = Join-Path $env:TEMP $zipName + + if (-not (Test-Path $tempZip)) { + $ok = $false + for ($i = 1; $i -le 5; $i++) { + try { + Invoke-WebRequest -Uri $url -OutFile $tempZip -UseBasicParsing + $ok = $true + break + } catch { + Warn "download attempt $i failed: $($_.Exception.Message)" + Start-Sleep -Seconds ($i * 2) + } + } + if (-not $ok) { Die "failed to download $url after 5 attempts" } + } + + if (Test-Path $Root) { + Remove-Item -Recurse -Force -LiteralPath $Root -ErrorAction SilentlyContinue + } + New-Item -ItemType Directory -Force -Path $Root | Out-Null + Expand-Archive -Path $tempZip -DestinationPath $Root -Force + + # Upstream ships `libopenblas.lib` + `libopenblas.dll`. Create + # openblas.lib / openblas.dll aliases so the standard + # `cargo:rustc-link-lib=openblas` directive resolves. + $libDir = Join-Path $Root "lib" + $binDir = Join-Path $Root "bin" + foreach ($pair in @(@($libDir, "libopenblas.lib", "openblas.lib"), + @($binDir, "libopenblas.dll", "openblas.dll"))) { + $dir = $pair[0]; $src = Join-Path $dir $pair[1]; $dst = Join-Path $dir $pair[2] + if ((Test-Path $src) -and -not (Test-Path $dst)) { + Copy-Item $src $dst -Force + Ok "aliased $($pair[1]) -> $($pair[2])" + } + } +} + +$found = Validate-OpenBLAS $Root +if (-not $found) { + Die "openblas.lib / openblas.dll not found under $Root after install" +} + +# Upstream OpenBLAS 0.3.30 ships libopenblas.dll statically linked against +# the MinGW runtime — `dumpbin /dependents` shows only KERNEL32.dll + +# msvcrt.dll, so we don't need to bundle libgcc/libgfortran/etc. If a +# future OpenBLAS release switches to a non-static build, look at the +# `OPENBLAS_RUNTIME_DLLS` history in git for the previous code path. +Export-Env "OPENBLAS_DIR" $Root +Export-Env "OPENBLAS_LIB_DIR" (Join-Path $Root "lib") +Export-Env "OPENBLAS_DLL" $found.Dll +Export-Path $found.Bin + +Ok "OPENBLAS_DIR=$Root" +Ok "OPENBLAS_DLL=$($found.Dll)" diff --git a/scripts/lint-tiny-text.mjs b/scripts/lint-tiny-text.mjs new file mode 100644 index 00000000..b4cf276d --- /dev/null +++ b/scripts/lint-tiny-text.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// Lint rule: forbid arbitrary Tailwind text-size classes below the +// design-system minimum (`text-ui-2xs` = 11px / 0.6875rem). +// +// Background: a visual-layout audit (see `src/tests/visual-layout.spec.ts`) +// flagged 1680 instances of computed font-size < 9px across 270 viewport +// combinations. The root cause was a mix of (a) too-small design tokens +// (since fixed) and (b) ad-hoc `text-[0.42rem]` style overrides bypassing +// the system. This script catches future (b)-class regressions. +// +// What we forbid: +// • `text-[N rem]` where N < 0.6875 (≈ 11px floor) +// • `text-[Npx]` where N < 11 +// What we allow: +// • `text-ui-2xs` … `text-ui-xl` (design-system tokens) +// • `text-[≥0.6875rem]` and `text-[≥11px]` (arbitrary but readable) +// • `text-xs` / `text-sm` / `text-base` … (Tailwind defaults — sized +// at 0.75rem+ which already meets the floor) +// +// Run via: node scripts/lint-tiny-text.mjs +// CI hook: wired into scripts/test-all.sh as the `tiny-text` suite. + +import * as fs from "node:fs"; +import * as path from "node:path"; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const SRC = path.join(ROOT, "src"); + +// Minimum readable size in pixels. Matches `--text-ui-2xs` in app.css. +// Don't lower without re-running the visual-layout audit. +const MIN_PX = 11; + +// File extensions that can carry Tailwind class strings. +const EXTS = new Set([".svelte", ".ts", ".tsx", ".js", ".jsx", ".html", ".astro"]); + +const SKIP_DIRS = new Set(["node_modules", ".svelte-kit", "build", "dist", "test-results"]); + +/** rem-or-px arbitrary text size. Captures the inner value verbatim. */ +const TEXT_ARBITRARY = /\btext-\[\s*([0-9]+(?:\.[0-9]+)?)\s*(rem|px|em)\s*\]/g; + +function pixelsFor(value, unit) { + switch (unit) { + case "px": + return value; + case "rem": + case "em": + return value * 16; // 1rem = 1em (in this context) = 16px at default html font-size + default: + return Number.NaN; + } +} + +function* walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + if (SKIP_DIRS.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* walk(full); + else if (EXTS.has(path.extname(entry.name))) yield full; + } +} + +const hits = []; +let scanned = 0; + +for (const file of walk(SRC)) { + scanned++; + const text = fs.readFileSync(file, "utf8"); + // Cheap pre-filter: skip files with no `text-[` at all. + if (!text.includes("text-[")) continue; + + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + let m; + const re = new RegExp(TEXT_ARBITRARY.source, "g"); + while ((m = re.exec(line)) !== null) { + const value = Number.parseFloat(m[1]); + const unit = m[2]; + const px = pixelsFor(value, unit); + if (Number.isNaN(px)) continue; + if (px < MIN_PX) { + hits.push({ + file: path.relative(ROOT, file), + line: i + 1, + col: m.index + 1, + className: m[0], + pixels: px, + }); + } + } + } +} + +const banner = "── tiny-text lint ──"; +if (hits.length === 0) { + console.log(`${banner} ${scanned} files scanned, 0 violations`); + process.exit(0); +} + +console.error(`${banner} ${hits.length} violation${hits.length === 1 ? "" : "s"} (minimum ${MIN_PX}px):`); +console.error(""); +for (const h of hits) { + console.error(` ${h.file}:${h.line}:${h.col} ${h.className} → ${h.pixels.toFixed(2)}px`); +} +console.error(""); +console.error("Use a design-system token instead:"); +console.error(" text-ui-2xs (11px) text-ui-xs (12px) text-ui-sm (13px)"); +console.error(" text-ui-base (14px) text-ui-md (15px) text-ui-lg (16px) text-ui-xl (18px)"); +console.error(""); +console.error(`If a smaller size is genuinely required, raise the floor in scripts/lint-tiny-text.mjs (currently ${MIN_PX}px)`); +console.error("after running src/tests/visual-layout.spec.ts to confirm readability across viewports."); +process.exit(1); diff --git a/scripts/package-linux-dist.sh b/scripts/package-linux-dist.sh index f9e1bfcc..881cc30c 100755 --- a/scripts/package-linux-dist.sh +++ b/scripts/package-linux-dist.sh @@ -85,26 +85,29 @@ mkdir -p "$package_root/resources" cp "$binary_path" "$package_root/skill" chmod +x "$package_root/skill" -# ── Bundle skill-daemon Tauri sidecar ───────────────────────────────────────── +# ── Bundle skill-daemon + skill-tty Tauri sidecars ─────────────────────────── # Try the release target directory first (CI build), then Tauri sidecar dir. -daemon_candidates=( - "$ROOT_DIR/src-tauri/target/$target/release/skill-daemon" - "$ROOT_DIR/src-tauri/binaries/skill-daemon-${target}" -) -daemon_found=0 -for sidecar_bin in "${daemon_candidates[@]}"; do - if [[ -f "$sidecar_bin" ]]; then - cp "$sidecar_bin" "$package_root/skill-daemon" - chmod +x "$package_root/skill-daemon" - echo "✓ Bundled skill-daemon sidecar: $sidecar_bin" - daemon_found=1 - break - fi -done -if [[ "$daemon_found" -eq 0 ]]; then - echo "⚠ skill-daemon sidecar not found for $target" >&2 - echo " Checked: ${daemon_candidates[*]}" >&2 -fi +bundle_sidecar() { + local name="$1" + local candidates=( + "$ROOT_DIR/src-tauri/target/$target/release/$name" + "$ROOT_DIR/src-tauri/binaries/${name}-${target}" + ) + for sidecar_bin in "${candidates[@]}"; do + if [[ -f "$sidecar_bin" ]]; then + cp "$sidecar_bin" "$package_root/$name" + chmod +x "$package_root/$name" + echo "✓ Bundled $name sidecar: $sidecar_bin" + return 0 + fi + done + echo "⚠ $name sidecar not found for $target" >&2 + echo " Checked: ${candidates[*]}" >&2 + return 1 +} + +bundle_sidecar skill-daemon || true +bundle_sidecar skill-tty || true # ── Bundle ONNX Runtime shared library ─────────────────────────────────────── # ort-sys downloads libonnxruntime.so into Cargo's OUT_DIR at build time. diff --git a/scripts/package-linux-system-bundles.sh b/scripts/package-linux-system-bundles.sh index f9f8f319..a5dfa82d 100755 --- a/scripts/package-linux-system-bundles.sh +++ b/scripts/package-linux-system-bundles.sh @@ -67,6 +67,7 @@ if ! command -v rpmbuild >/dev/null 2>&1; then fi version="$(node -p "JSON.parse(require('fs').readFileSync('$ROOT_DIR/package.json','utf8')).version")" +rpm_version="${version//-/\~}" binary_path="$ROOT_DIR/src-tauri/target/$target/release/skill" resources_dir="$ROOT_DIR/src-tauri/resources" @@ -121,6 +122,21 @@ else echo "⚠ skill-daemon not found at $daemon_path" >&2 fi +# ── Bundle skill-tty sidecar ───────────────────────────────────────────────── +# The PTY proxy that wraps the user's shell for terminal-session recording. +# Lives next to skill-daemon so the shell hook (and the daemon's tty exec-shim) +# can find it via current_exe()'s parent directory. Splitting it out means +# blanket process-name kills of skill-daemon don't sweep up active recorded +# shells. +tty_path="$ROOT_DIR/src-tauri/target/$target/release/skill-tty" +if [[ -f "$tty_path" ]]; then + cp "$tty_path" "$stage_root/opt/neuroskill/skill-tty" + chmod +x "$stage_root/opt/neuroskill/skill-tty" + echo "✓ Bundled skill-tty sidecar" +else + echo "⚠ skill-tty not found at $tty_path" >&2 +fi + # ── Bundle ONNX Runtime shared library ─────────────────────────────────────── # ort-sys downloads libonnxruntime.so into Cargo's OUT_DIR at build time. # The binary links against it dynamically (DT_NEEDED: libonnxruntime.so.1). @@ -199,7 +215,7 @@ tar -czf "$rpm_top/SOURCES/neuroskill-root.tar.gz" -C "$work_root" "$(basename " cat > "$rpm_top/SPECS/neuroskill.spec" < - $version-1 +* $(date '+%a %b %d %Y') NeuroSkill CI - $rpm_version-1 - CI system-tool Linux package build EOF diff --git a/scripts/prepare-daemon-sidecar.js b/scripts/prepare-daemon-sidecar.js index 64317ad8..f9c91b54 100644 --- a/scripts/prepare-daemon-sidecar.js +++ b/scripts/prepare-daemon-sidecar.js @@ -40,47 +40,65 @@ function runOrThrow(cmd, args) { } const triple = process.env.SKILL_DAEMON_TARGET || detectTargetTriple(); -console.log(`🔧 Building skill-daemon for ${triple || "native"} (release)…`); +const ext = triple.includes("windows") ? ".exe" : ""; +const tripleLabel = triple || "native"; +const isWindows = triple.includes("windows") || platform() === "win32"; + +// skill-tty is unix-only — Windows shell hooks don't invoke it. Build it on +// macOS/Linux only so the Windows pipeline doesn't waste CI minutes on it. +const cratesToBuild = ["skill-daemon"]; +if (!isWindows) { + cratesToBuild.push("skill-tty"); +} + +console.log(`🔧 Building ${cratesToBuild.join(", ")} for ${triple || "native"} (release)…`); -const cargoArgs = ["build", "-p", "skill-daemon", "--release"]; +const cargoArgs = ["build", "--release"]; +for (const c of cratesToBuild) { + cargoArgs.push("-p", c); +} if (triple) { cargoArgs.push("--target", triple); } runOrThrow("cargo", cargoArgs); -const ext = triple.includes("windows") ? ".exe" : ""; -const candidates = [ - triple ? resolve(targetDir, triple, "release", `skill-daemon${ext}`) : null, - resolve(targetDir, "release", `skill-daemon${ext}`), -].filter(Boolean); - -const src = candidates.find((p) => existsSync(p)); -if (!src) { - console.error("❌ skill-daemon binary not found after build"); - process.exit(1); -} - mkdirSync(binDir, { recursive: true }); -const tripleLabel = triple || "native"; -const dst = resolve(binDir, `skill-daemon-${tripleLabel}${ext}`); -copyFileSync(src, dst); -try { - chmodSync(dst, 0o755); -} catch { - // Windows may ignore chmod; safe to continue. -} +function stageBinary(name) { + const candidates = [ + triple ? resolve(targetDir, triple, "release", `${name}${ext}`) : null, + resolve(targetDir, "release", `${name}${ext}`), + ].filter(Boolean); -const releaseDir = triple ? resolve(targetDir, triple, "release") : resolve(targetDir, "release"); -const releaseDst = resolve(releaseDir, `skill-daemon${ext}`); -if (existsSync(releaseDir) && src !== releaseDst) { - copyFileSync(src, releaseDst); - console.log(`Copied to ${releaseDst}`); -} else if (src === releaseDst) { - console.log(`Daemon already at ${releaseDst}`); + const src = candidates.find((p) => existsSync(p)); + if (!src) { + console.error(`❌ ${name} binary not found after build`); + process.exit(1); + } + + const dst = resolve(binDir, `${name}-${tripleLabel}${ext}`); + copyFileSync(src, dst); + try { + chmodSync(dst, 0o755); + } catch { + // Windows may ignore chmod; safe to continue. + } + + const releaseDir = triple ? resolve(targetDir, triple, "release") : resolve(targetDir, "release"); + const releaseDst = resolve(releaseDir, `${name}${ext}`); + if (existsSync(releaseDir) && src !== releaseDst) { + copyFileSync(src, releaseDst); + console.log(`Copied to ${releaseDst}`); + } else if (src === releaseDst) { + console.log(`${name} already at ${releaseDst}`); + } + + const size = statSync(dst).size; + const mb = (size / (1024 * 1024)).toFixed(1); + console.log(`✅ ${name} sidecar ready: ${dst} (${mb} MiB)`); } -const size = statSync(dst).size; -const mb = (size / (1024 * 1024)).toFixed(1); -console.log(`✅ Daemon sidecar ready: ${dst} (${mb} MiB)`); +for (const c of cratesToBuild) { + stageBinary(c); +} diff --git a/scripts/release.js b/scripts/release.js index 00136269..87d467f7 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -19,9 +19,27 @@ // works if no rebuild happens at promotion time. import { execSync, spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { baseVersion, bumpVersion } from "./version-utils.mjs"; +// GitHub caps PR/issue bodies at 65_536 chars. Leave headroom for the +// surrounding template; truncate the embedded notes if they exceed this. +const NOTES_MAX_CHARS = 50_000; + +function readReleaseNotes(version) { + const path = `changes/releases/${version}.md`; + if (!existsSync(path)) return null; + let body = readFileSync(path, "utf8").trim(); + // The file leads with `## [] — ` which is redundant with the + // PR's surrounding heading; strip it so the embedded section starts at the + // first content heading (Features / Bugfixes / etc.). + body = body.replace(/^##\s+\[[^\]]+\][^\n]*\n+/, ""); + if (body.length > NOTES_MAX_CHARS) { + body = `${body.slice(0, NOTES_MAX_CHARS)}\n\n_…notes truncated — see \`changes/releases/${version}.md\` for the full text._`; + } + return body; +} + // ── Shell + git helpers ───────────────────────────────────────────────────── function sh(cmd, args, opts = {}) { @@ -59,6 +77,76 @@ function gitTracksRemote(b) { return captureOut(`git for-each-ref --format=%(upstream:short) refs/heads/${b}`).length > 0; } +function gitTagExistsLocal(tag) { + return sh("git", ["rev-parse", "--verify", `refs/tags/${tag}`], { capture: true }).status === 0; +} + +function gitTagExistsOnAnyRemote(tag) { + const remotes = captureOut("git remote") + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + for (const remote of remotes) { + const r = sh("git", ["ls-remote", "--tags", "--exit-code", remote, `refs/tags/${tag}`], { capture: true }); + if (r.status === 0) return true; + } + return false; +} + +function gitHeadPackageVersion() { + // Read package.json at HEAD to confirm the current commit is the bump for `currentVersion`. + const out = sh("git", ["show", "HEAD:package.json"], { capture: true }); + if (out.status !== 0) return null; + try { + return JSON.parse(out.stdout).version || null; + } catch { + return null; + } +} + +/// Self-heal a half-finished previous iteration: if `currentVersion`'s tag +/// is missing locally or on the remote, push the branch (if needed) and +/// create + push the tag before we try to bump. Without this, every aborted +/// push (failed pre-push hook, killed CI, network blip) wedges the release +/// branch until someone runs `npm run tag` by hand. +function ensureCurrentVersionTagged({ currentVersion, branchName, onReleaseBranch }) { + if (!onReleaseBranch) return; // Cutting from main — there's no prior version on this branch to tag. + + const tag = `v${currentVersion}`; + const haveLocal = gitTagExistsLocal(tag); + const haveRemote = haveLocal && gitTagExistsOnAnyRemote(tag); + + if (haveLocal && haveRemote) return; // Nothing to recover. + + // Sanity: HEAD's package.json must match `currentVersion`. If it doesn't, + // we're not on the bump commit and tagging here would produce a wrong tag. + const headVersion = gitHeadPackageVersion(); + if (headVersion !== currentVersion) { + fail( + `Cannot self-heal: HEAD's package.json version (${headVersion ?? "unknown"}) doesn't match the ` + + `current version (${currentVersion}). Resolve manually: tag the right commit, push, then re-run.`, + ); + } + + log(`recovering: tag ${tag} is missing — completing the previous iteration first`); + + // The remote-tag check requires HEAD's commit to be reachable on the remote, + // so the branch must be pushed before we push the tag. + // --no-verify: bump's preflight already ran the full suite; skip the pre-push hook. + if (!gitTracksRemote(branchName)) { + log(`git push -u origin ${branchName} (recovery)`); + sh("git", ["push", "--no-verify", "-u", "origin", branchName], { check: true }); + } else { + log("git push (recovery)"); + sh("git", ["push", "--no-verify"], { check: true }); + } + + log("npm run tag (recovery)"); + sh("npm", ["run", "tag"], { check: true }); + + ok(`recovered: ${tag} tagged and pushed; resuming next-RC iteration`); +} + function ensureGhReady() { if (sh("gh", ["--version"], { capture: true }).status !== 0) { fail("`gh` (GitHub CLI) not installed. Install with `brew install gh` then `gh auth login`."); @@ -192,7 +280,15 @@ async function main() { sh("git", ["checkout", "-b", branchName], { check: true }); } - // ── 2. Run bump (mutates files, runs preflight, creates commit) ──────── + // ── 2. Self-heal: tag any prior iteration that didn't get pushed ─────── + // The previous run can die mid-flight (failed pre-push hook, killed CI, + // network blip) after the bump commit but before `npm run tag`. That + // leaves the release branch in a state where bump's preflight refuses to + // run because the current version isn't tagged. Detect + recover here so + // the user doesn't need to remember the manual `npm run tag` dance. + ensureCurrentVersionTagged({ currentVersion, branchName, onReleaseBranch }); + + // ── 3. Run bump (mutates files, runs preflight, creates commit) ──────── const bumpArgs = ["run", "bump", "--", "--rc"]; if (force) bumpArgs.push("--force"); log(`npm ${bumpArgs.join(" ")}`); @@ -210,12 +306,14 @@ async function main() { } // ── 3. Push branch ────────────────────────────────────────────────────── + // --no-verify: bump's preflight already ran the full test suite; skip the + // pre-push hook to avoid re-running the same cargo/vitest checks. if (!gitTracksRemote(branchName)) { log(`git push -u origin ${branchName}`); - sh("git", ["push", "-u", "origin", branchName], { check: true }); + sh("git", ["push", "--no-verify", "-u", "origin", branchName], { check: true }); } else { log("git push"); - sh("git", ["push"], { check: true }); + sh("git", ["push", "--no-verify"], { check: true }); } // ── 4. Tag + push tag (existing primitive) ───────────────────────────── @@ -231,9 +329,11 @@ async function main() { prs = JSON.parse(prList.stdout || "[]"); } catch {} + const notes = readReleaseNotes(newVersion); + if (prs.length === 0) { log("gh pr create"); - const body = [ + const sections = [ `## Release v${base}`, "", `Tracking release candidates for **v${base}**.`, @@ -251,7 +351,11 @@ async function main() { `- ${tag}`, "", "_(more added as RCs are cut)_", - ].join("\n"); + ]; + if (notes) { + sections.push("", "---", "", `## What's in this release (\`${tag}\`)`, "", notes); + } + const body = sections.join("\n"); sh( "gh", [ @@ -273,11 +377,15 @@ async function main() { } else { const pr = prs[0]; log(`gh pr comment ${pr.number}`); - const body = [ + const sections = [ `🚀 New RC: \`${tag}\``, "", "CI is building. Once the workflow finishes, RC channel users will receive this build automatically on their next update check.", - ].join("\n"); + ]; + if (notes) { + sections.push("", "
Release notes for this RC", "", notes, "", "
"); + } + const body = sections.join("\n"); sh("gh", ["pr", "comment", String(pr.number), "--body", body], { check: true }); } diff --git a/scripts/run-upgrade-tests-in-container.sh b/scripts/run-upgrade-tests-in-container.sh new file mode 100755 index 00000000..b0080649 --- /dev/null +++ b/scripts/run-upgrade-tests-in-container.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Runs the daemon-upgrade e2e tests inside the container built from +# Dockerfile.upgrade-test. Picks the scope via the SCOPE env var. + +set -euo pipefail + +cd /work + +# Tmpfs for state isolation. /tmp is already tmpfs in Docker; ensure +# SKILL_DAEMON_CONFIG_ROOT is unset so each test picks its own tmpdir. +unset SKILL_DAEMON_CONFIG_ROOT +export RUST_BACKTRACE=1 + +echo "==> rustc: $(rustc --version)" +echo "==> python: $(python3 --version)" +echo "==> scope: ${SCOPE:-A}" +echo + +case "${SCOPE:-A}" in + A) + echo "==> Scope A: daemon_upgrade primitives e2e" + # Single-thread because tests share SKILL_DAEMON_CONFIG_ROOT semantics + # via env-var lock; --test-threads=1 avoids inter-test interference. + cargo test \ + --manifest-path src-tauri/Cargo.toml \ + --lib --no-default-features \ + linux_e2e \ + -- --test-threads=1 --nocapture + ;; + B) + echo "==> Scope B: orchestrator e2e against Python /v1/version stub" + # We deliberately don't build the real skill-daemon: the orchestrator's + # contract with it is just /v1/version + pidfile + port-bind. A 25-line + # Python stub gives identical coverage in ~5s instead of ~5min, and skips + # the llama-cpp-sys / libclang / GPU build chain. + cargo test \ + --manifest-path src-tauri/Cargo.toml \ + --lib --no-default-features \ + orchestrator_linux_e2e \ + -- --test-threads=1 --nocapture + ;; + AB|both) + SCOPE=A "$0" + SCOPE=B "$0" + ;; + *) + echo "unknown SCOPE=${SCOPE}" >&2 + exit 2 + ;; +esac + +echo +echo "==> done." diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 9474c361..c46fff93 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1,40 +1,135 @@ #!/usr/bin/env bash # smoke-test.sh — Launch the Skill app and run test.ts once it's ready. # +# Two modes, auto-selected: +# • headless (default in CI / non-TTY): app runs in the background, logs to +# a file; test.ts runs in the foreground with a bounded discovery timeout; +# the app is terminated on exit and the test's exit status propagates. +# • tmux (default in interactive shells): app + test.ts run in a split-pane +# tmux session you can attach to. Same behaviour as before. +# +# Override the mode with SMOKE_MODE=headless|tmux. Override the headless +# discovery + run timeout with SMOKE_TIMEOUT_SECS (default 180). +# # Usage: -# ./smoke-test.sh # auto-discover port via mDNS (retries until Ctrl-C) +# ./smoke-test.sh # auto-discover port # ./smoke-test.sh 62853 # pass explicit port to test.ts # ./smoke-test.sh --http # forward flags to test.ts # ./smoke-test.sh 62853 --ws # combine port + flags # -# Requires: tmux, Node ≥ 18 +# Requires: Node ≥ 18 (tmux only used in interactive mode). set -euo pipefail -SESSION="smoke" DIR="$(cd "$(dirname "$0")/.." && pwd)" -TEST_ARGS="${*:-}" # forward all args to test.ts - -# Kill previous session if it exists -tmux kill-session -t "$SESSION" 2>/dev/null || true - -tmux new-session -d -s "$SESSION" -c "$DIR" \ - "echo '═══ Starting Skill app ═══'; npm run tauri dev; echo '═══ App exited ═══'; read" \; \ - split-window -h -c "$DIR" "\ - echo '═══ Waiting for Skill to start… ═══' - sleep 5 - npx tsx test.ts $TEST_ARGS - STATUS=\$? - echo '' - if [ \$STATUS -eq 0 ]; then - echo '══════════════════════════' - echo ' ✓ SMOKE TEST PASSED' - echo '══════════════════════════' - else - echo '══════════════════════════' - echo ' ✗ SMOKE TEST FAILED' - echo '══════════════════════════' +TIMEOUT_SECS="${SMOKE_TIMEOUT_SECS:-180}" + +# ── Mode selection ──────────────────────────────────────────────────────────── +# +# Pick headless when stdout isn't a TTY (CI, log capture), or when CI=true, +# or when tmux is unavailable. Otherwise use the tmux split-pane. +choose_mode() { + if [ -n "${SMOKE_MODE:-}" ]; then + echo "$SMOKE_MODE" + return + fi + if [ "${CI:-}" = "true" ] || [ ! -t 1 ] || ! command -v tmux >/dev/null 2>&1; then + echo "headless" + else + echo "tmux" + fi +} +MODE="$(choose_mode)" + +# ── Headless mode ───────────────────────────────────────────────────────────── +run_headless() { + cd "$DIR" + local app_log + app_log="$(mktemp -t skill-smoke-app.XXXXXX.log)" + + echo "→ smoke (headless) — log: $app_log timeout: ${TIMEOUT_SECS}s" + + # Enable job control so the background `npm run tauri dev` becomes its own + # process group leader (PGID = PID). Without this, the npm → tauri → cargo → + # app chain inherits the script's PGID and a single SIGTERM only hits npm, + # leaving cargo + the app holding the listening port. + set -m + npm run tauri dev >"$app_log" 2>&1 & + local app_pid=$! + set +m + echo "→ app pid: $app_pid (process group leader)" + + cleanup() { + if kill -0 "$app_pid" 2>/dev/null; then + echo "→ stopping app (PID $app_pid)" + # Kill the whole process group: `npm run tauri dev` spawns a chain + # (npm → tauri → cargo → app), and SIGTERM on the parent alone leaves + # the cargo+app children orphaned to occupy the port. + kill -TERM -- "-$app_pid" 2>/dev/null || kill -TERM "$app_pid" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$app_pid" 2>/dev/null || break + sleep 1 + done + kill -KILL -- "-$app_pid" 2>/dev/null || kill -KILL "$app_pid" 2>/dev/null || true fi - echo 'Press Enter to close.'; read - exit \$STATUS" \; \ - attach + } + trap cleanup EXIT INT TERM + + # Hand the discovery timeout to test.ts so its retry loop exits cleanly + # if the app fails to register on mDNS. Reserve ~10s for the test run + # itself to start, but never less than 30s. + local discover_secs=$(( TIMEOUT_SECS - 10 )) + if [ "$discover_secs" -lt 30 ]; then discover_secs=30; fi + + local status=0 + SKILL_DISCOVER_TIMEOUT_SECS="$discover_secs" \ + npx tsx test.ts "$@" || status=$? + + echo + if [ "$status" -eq 0 ]; then + echo "══════════════════════════" + echo " ✓ SMOKE TEST PASSED" + echo "══════════════════════════" + else + echo "══════════════════════════" + echo " ✗ SMOKE TEST FAILED (exit $status)" + echo "──── App log (last 100 lines) ────" + tail -n 100 "$app_log" || true + echo "══════════════════════════" + fi + exit "$status" +} + +# ── Interactive tmux mode ───────────────────────────────────────────────────── +run_tmux() { + local session="smoke" + local test_args + test_args="$*" + tmux kill-session -t "$session" 2>/dev/null || true + tmux new-session -d -s "$session" -c "$DIR" \ + "echo '═══ Starting Skill app ═══'; npm run tauri dev; echo '═══ App exited ═══'; read" \; \ + split-window -h -c "$DIR" "\ + echo '═══ Waiting for Skill to start… ═══' + sleep 5 + npx tsx test.ts $test_args + STATUS=\$? + echo '' + if [ \$STATUS -eq 0 ]; then + echo '══════════════════════════' + echo ' ✓ SMOKE TEST PASSED' + echo '══════════════════════════' + else + echo '══════════════════════════' + echo ' ✗ SMOKE TEST FAILED' + echo '══════════════════════════' + fi + echo 'Press Enter to close.'; read + exit \$STATUS" \; \ + attach +} + +case "$MODE" in + headless) run_headless "$@" ;; + tmux) run_tmux "$@" ;; + *) echo "unknown SMOKE_MODE: $MODE (expected: headless | tmux)" >&2; exit 2 ;; +esac diff --git a/scripts/sync-llm-catalog.js b/scripts/sync-llm-catalog.js index df308d22..3a583960 100644 --- a/scripts/sync-llm-catalog.js +++ b/scripts/sync-llm-catalog.js @@ -12,7 +12,9 @@ const writeMode = args.has("--write"); const checkMode = args.has("--check") || !writeMode; const verbose = args.has("--verbose"); -function log(..._msg) {} +function log(...msg) { + if (verbose) console.log(...msg); +} function normalizeQuant(value) { return value ? value.toUpperCase() : "UNKNOWN"; @@ -123,6 +125,95 @@ async function loadCatalog() { return JSON.parse(raw); } +/** Inflate normalized `{ families, models }` or legacy `{ entries }` to flat entries. */ +function inflateCatalog(catalog) { + if (Array.isArray(catalog.entries)) { + return { + active_model: catalog.active_model ?? "", + active_mmproj: catalog.active_mmproj ?? "", + entries: catalog.entries, + }; + } + + const entries = []; + for (const m of catalog.models ?? []) { + const fam = catalog.families?.[m.family]; + if (!fam) continue; + entries.push({ + repo: m.repo ?? fam.repo, + filename: m.filename, + remote_filename: m.remote_filename ?? null, + quant: m.quant, + size_gb: m.size_gb, + description: m.description, + family_id: m.family, + family_name: fam.name, + family_desc: fam.description, + tags: fam.tags ?? [], + is_mmproj: fam.is_mmproj ?? false, + mtp: fam.mtp ?? false, + recommended: m.recommended ?? false, + advanced: m.advanced ?? false, + params_b: fam.params_b ?? 0, + max_context_length: fam.max_context_length ?? 0, + shard_files: m.shard_files ?? [], + }); + } + + return { + active_model: catalog.active_model ?? "", + active_mmproj: catalog.active_mmproj ?? "", + entries, + }; +} + +/** Deflate flat entries back to the normalized on-disk format. */ +function deflateCatalog(flat) { + const families = {}; + const models = []; + + for (const e of flat.entries) { + if (!families[e.family_id]) { + families[e.family_id] = { + name: e.family_name, + description: e.family_desc, + repo: e.repo, + tags: e.tags ?? [], + is_mmproj: e.is_mmproj ?? false, + mtp: e.mtp ?? false, + params_b: e.params_b ?? 0, + max_context_length: e.max_context_length ?? 0, + }; + } + + const fam = families[e.family_id]; + const model = { + family: e.family_id, + filename: e.filename, + quant: e.quant, + size_gb: e.size_gb, + description: e.description, + }; + if (e.remote_filename) model.remote_filename = e.remote_filename; + if (e.repo !== fam.repo) model.repo = e.repo; + if (e.recommended) model.recommended = true; + if (e.advanced) model.advanced = true; + if (e.shard_files?.length) model.shard_files = e.shard_files; + models.push(model); + } + + return { + active_model: flat.active_model ?? "", + active_mmproj: flat.active_mmproj ?? "", + families, + models, + }; +} + +function remoteKey(entry) { + return entry.remote_filename ?? entry.filename; +} + function createAddedEntry(filename, siblingMap, template) { const isMmproj = /mmproj/i.test(filename); const quant = inferQuant(filename, isMmproj); @@ -166,8 +257,13 @@ function pruneMmprojOnlyFamilies(entries) { } async function syncCatalog() { - const catalog = await loadCatalog(); - const originalEntries = catalog.entries; + const onDisk = await loadCatalog(); + const catalog = inflateCatalog(onDisk); + const originalEntries = catalog.entries ?? []; + + if (originalEntries.length === 0) { + throw new Error("llm_catalog.json has no model entries (expected families/models or entries)"); + } const repos = uniqueRepoOrder(originalEntries); const repoEntryMap = new Map(); @@ -213,9 +309,13 @@ async function syncCatalog() { ); const existingFilenames = new Set(existing.map((entry) => entry.filename)); + const existingRemoteKeys = new Set(existing.map((entry) => remoteKey(entry))); for (const entry of existing) { - if (!remoteGguf.has(entry.filename)) { + const key = remoteKey(entry); + // Keep sharded / subdir entries — HF root-only listing won't include them. + if (entry.shard_files?.length || key.includes("/")) continue; + if (!remoteGguf.has(key)) { removedKeys.add(`${entry.repo}::${entry.filename}`); } } @@ -224,7 +324,7 @@ async function syncCatalog() { const newEntries = []; for (const filename of remoteGguf) { - if (existingFilenames.has(filename)) continue; + if (existingFilenames.has(filename) || existingRemoteKeys.has(filename)) continue; newEntries.push(createAddedEntry(filename, siblingMap, templateModel)); } @@ -263,8 +363,13 @@ async function syncCatalog() { const prunedEntries = pruneMmprojOnlyFamilies(mergedEntries); stats.removed += mergedEntries.length - prunedEntries.length; - const nextCatalog = { ...catalog, entries: prunedEntries }; - const changed = JSON.stringify(nextCatalog) !== JSON.stringify(catalog); + const nextFlat = { + active_model: catalog.active_model, + active_mmproj: catalog.active_mmproj, + entries: prunedEntries, + }; + const nextCatalog = deflateCatalog(nextFlat); + const changed = JSON.stringify(nextCatalog) !== JSON.stringify(onDisk); return { changed, nextCatalog, stats }; } @@ -296,6 +401,7 @@ async function main() { log("catalog updated"); } -main().catch((_error) => { +main().catch((error) => { + console.error(`sync-llm-catalog: ${error?.stack ?? error}`); process.exit(1); }); diff --git a/scripts/tauri-build.js b/scripts/tauri-build.js index b07d7d8a..b9e7caf9 100644 --- a/scripts/tauri-build.js +++ b/scripts/tauri-build.js @@ -341,6 +341,18 @@ if (isMingwTarget) { platformFlags = ["--target", "aarch64-apple-darwin", "--no-sign"]; } + // Homebrew cmake/sccache are not on the default PATH when cargo invokes + // build scripts. The cmake-0.1.x crate respects CMAKE as the binary path. + // Kept out of .cargo/config.toml because [env] is unconditional and would + // leak these macOS paths to Windows / Linux runners (cmake-rs panics with + // "is `cmake` not installed?" / os error 3). + if (!process.env.CMAKE && existsSync("/opt/homebrew/bin/cmake")) { + process.env.CMAKE = "/opt/homebrew/bin/cmake"; + } + if (!process.env.SCCACHE_PATH && existsSync("/opt/homebrew/bin/sccache")) { + process.env.SCCACHE_PATH = "/opt/homebrew/bin/sccache"; + } + // ── macOS: skip Tauri bundling for default local builds ────────────────── // // On recent macOS runners/hosts, the Tauri CLI can crash in the @@ -959,9 +971,14 @@ if (subcommand === "dev") { let daemonChild = null; if (subcommand === "dev" && !tuiTauriPane) { - console.log("\n🔧 Building skill-daemon…"); + console.log("\n🔧 Building skill-daemon + skill-tty…"); try { + // skill-tty is the sibling PTY proxy; build it alongside the daemon so + // dev shells exec into a separate process (and aren't killed when Tauri + // hot-reloads the daemon). Windows doesn't use the PTY proxy. const daemonBuildArgs = ["build", "-p", "skill-daemon"]; + const isWin = process.platform === "win32" || (explicitTarget || "").includes("windows"); + if (!isWin) daemonBuildArgs.push("-p", "skill-tty"); if (explicitTarget) daemonBuildArgs.push("--target", explicitTarget); execFileSync("cargo", daemonBuildArgs, { cwd: root, stdio: "inherit", env: process.env }); diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 92cca301..539fdfc1 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -48,8 +48,8 @@ for arg in "$@"; do echo " hooks pre-commit + pre-push" exit 0 ;; - fast) SUITES+=(fmt lint clippy vitest rust ci types) ;; - all) SUITES+=(fmt lint clippy deny vitest rust:all ci types widgets a11y i18n changelog smoke daemon e2e mlx-e2e) ;; + fast) SUITES+=(fmt lint tiny-text clippy vitest rust ci types) ;; + all) SUITES+=(fmt lint tiny-text clippy deny vitest rust:all ci types widgets a11y i18n changelog smoke daemon e2e mlx-e2e) ;; hooks) SUITES+=(pre-commit pre-push) ;; *) SUITES+=("$arg") ;; esac @@ -123,6 +123,12 @@ for suite in "${SUITES[@]}"; do lint) run_suite "biome check" npx biome check src/ scripts/ || { $STOP_ON_FAIL && break; } ;; + tiny-text) + # Bans arbitrary `text-[<11px]` classes that bypass the design + # system. See scripts/lint-tiny-text.mjs for the rule rationale + # and src/tests/visual-layout.spec.ts for the audit it derives from. + run_suite "tiny-text lint" node scripts/lint-tiny-text.mjs || { $STOP_ON_FAIL && break; } + ;; clippy) run_suite "rust version check" node scripts/check-rust-version.mjs --verbose || { $STOP_ON_FAIL && break; } run_suite "cargo clippy (workspace)" cargo clippy --locked --workspace --exclude skill -- -D warnings || { $STOP_ON_FAIL && break; } @@ -154,11 +160,10 @@ for suite in "${SUITES[@]}"; do run_suite "Windows manifest" node scripts/check-windows-manifest.mjs || { $STOP_ON_FAIL && break; } ;; smoke) - if command -v tmux >/dev/null 2>&1 && [ -t 0 ]; then - run_suite "smoke test" bash scripts/smoke-test.sh || { $STOP_ON_FAIL && break; } - else - skip_suite "smoke test" "requires tmux + interactive terminal" - fi + # smoke-test.sh auto-selects headless mode when stdout isn't a TTY or + # CI=true, so it runs unattended in CI / piped shells. Interactive + # terminals get the tmux split-pane unless overridden by SMOKE_MODE. + run_suite "smoke test" bash scripts/smoke-test.sh || { $STOP_ON_FAIL && break; } ;; daemon) if ls src-tauri/target/*/release/bundle/dmg/*.dmg >/dev/null 2>&1 || \ @@ -185,10 +190,10 @@ for suite in "${SUITES[@]}"; do ;; mlx-e2e) if [[ "$(uname -s)" == "Darwin" ]]; then - run_suite "UMAP MLX E2E" cargo test -p skill-router --features mlx -- umap_e2e --nocapture --test-threads=1 || { $STOP_ON_FAIL && break; } - run_suite "FFT MLX E2E" cargo test -p skill-eeg --features mlx -- fft_e2e --nocapture || { $STOP_ON_FAIL && break; } + run_suite "UMAP GPU E2E" cargo test -p skill-router --features gpu -- umap_e2e --nocapture --test-threads=1 --include-ignored || { $STOP_ON_FAIL && break; } + run_suite "FFT Metal E2E" cargo test -p skill-eeg --features rlx-fft-metal -- --nocapture || { $STOP_ON_FAIL && break; } else - skip_suite "MLX E2E" "requires macOS with Apple Silicon" + skip_suite "GPU/Metal E2E" "requires macOS with Apple Silicon" fi ;; widgets) diff --git a/scripts/test-skill-tty-linux.sh b/scripts/test-skill-tty-linux.sh new file mode 100755 index 00000000..8f8e48de --- /dev/null +++ b/scripts/test-skill-tty-linux.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# ── Verify skill-tty compiles on Linux via docker ───────────────────────── +# +# Cross-compiling from macOS to Linux is fragile because of system deps +# (dbus, udev, etc.); this script runs the compile check inside a clean +# rust:bookworm container. +# +# Disk-safety notes: +# • Source is mounted READ-ONLY (/skill:ro) so the host's working tree +# can't be polluted from inside the container. +# • CARGO_TARGET_DIR is redirected to /tmp/cargo-target (tmpfs) so the +# ~10 GB of Linux build artifacts disappear when the container exits +# instead of accumulating in src-tauri/target/. +# • Cargo registry/git are cached in named volumes so repeat runs reuse +# downloads without consuming new layer space each time. +# +# Usage: +# bash scripts/test-skill-tty-linux.sh # quick: cargo check +# bash scripts/test-skill-tty-linux.sh --build # heavier: cargo build --release + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +mode="${1:---check}" +case "$mode" in + --check) cargo_cmd="check" ;; + --build) cargo_cmd="build --release" ;; + *) + echo "usage: $0 [--check|--build]" >&2 + exit 2 + ;; +esac + +echo "→ skill-tty Linux $cargo_cmd via docker (rust:1-bookworm)" +echo " source mount: $ROOT (ro)" +echo " cargo target: tmpfs inside container (no host-disk impact)" + +docker run --rm \ + -v "$ROOT:/skill:ro" \ + -v skill-cargo-registry:/usr/local/cargo/registry \ + -v skill-cargo-git:/usr/local/cargo/git \ + --tmpfs /tmp/cargo-target:size=20g,exec \ + -w /skill \ + -e CARGO_TARGET_DIR=/tmp/cargo-target \ + rust:1-bookworm \ + sh -c " + set -e + apt-get update -qq + apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev libudev-dev cmake clang >/dev/null + cargo $cargo_cmd -p skill-tty + echo + echo '✓ skill-tty Linux $cargo_cmd succeeded' + ls -la /tmp/cargo-target/*/skill-tty 2>/dev/null || true + " diff --git a/scripts/test-upgrade-linux-docker.sh b/scripts/test-upgrade-linux-docker.sh new file mode 100755 index 00000000..0bfc98d2 --- /dev/null +++ b/scripts/test-upgrade-linux-docker.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Run the daemon-upgrade end-to-end tests in a clean Linux container. +# +# Usage: +# scripts/test-upgrade-linux-docker.sh # Scope A (primitives) +# scripts/test-upgrade-linux-docker.sh B # Scope B (orchestrator) +# scripts/test-upgrade-linux-docker.sh both # A then B +# +# Uses BuildKit cache mounts so the cargo registry and target dir survive +# repeated runs — first run takes ~5–10 min, subsequent runs are seconds. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCOPE="${1:-A}" +IMAGE_TAG="skill-upgrade-test" + +cd "${REPO_ROOT}" + +echo "==> building image (Dockerfile.upgrade-test)…" +DOCKER_BUILDKIT=1 docker build \ + -f Dockerfile.upgrade-test \ + -t "${IMAGE_TAG}" \ + . + +# Persistent named volumes for cargo registry + target dir. Survive between +# runs of this script — drop them with `docker volume rm` to force-rebuild. +docker volume create skill-upgrade-cargo-registry >/dev/null +docker volume create skill-upgrade-target >/dev/null + +echo "==> running scope=${SCOPE}…" +exec docker run --rm \ + -e SCOPE="${SCOPE}" \ + -v skill-upgrade-cargo-registry:/usr/local/cargo/registry \ + -v skill-upgrade-target:/work/target \ + --tmpfs /tmp:rw,exec,size=512m \ + "${IMAGE_TAG}" diff --git a/skills b/skills index 09b527cc..37f54ddb 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 09b527cc90d1b25788daff51fdd9da9e0d1187d3 +Subproject commit 37f54ddb891acb7b3ec4e7ed17c8706e770ca289 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23c2c2ff..5719be49 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "skill" -version = "0.0.129" +version = "0.0.131-rc.6" description = "A Tauri App" authors = ["NeuroSkill authors"] edition = "2021" @@ -22,7 +22,7 @@ default = ["tts-kitten", "mw75-rfcomm", "gps"] gps = ["skill-data/gps"] mw75-rfcomm = ["skill-devices/mw75-rfcomm"] tts-kitten = ["dep:kittentts", "dep:rodio", "skill-tts/tts-kitten"] -tts-neutts = ["dep:neutts", "dep:rodio", "dep:sha2", "dep:hound", "skill-tts/tts-neutts"] +tts-neutts = ["dep:neutts", "dep:rodio", "dep:hound", "skill-tts/tts-neutts"] # ── Frontend asset embedding ────────────────────────────────────────────────── # @@ -100,11 +100,11 @@ csv = "1" # TTS — lightweight ONNX-based speech synthesis (English only for calibration) # Model weights (~30 MB) are downloaded from HuggingFace Hub on first use. # -# espeak feature: active on all platforms (macOS, Linux, Windows). -# On Windows, build-espeak-static.ps1 (PowerShell) builds libespeak-ng -# statically using CMake + MSVC, exactly as build-espeak-static.sh does on -# macOS/Linux. See WINDOWS.md for setup instructions. -kittentts = { version = "0.4.1", features = ["espeak"], optional = true } +# espeak feature: active on macOS and Linux. Windows skips KittenTTS entirely +# so we don't have to ship onnxruntime.dll alongside the installer — the +# tts-kitten feature becomes a no-op on Windows because the optional dep is +# only declared for non-Windows targets (see [target.cfg(...)] block below). +# When NeuTTS or another TTS backend lands for Windows, hook it in there. # NeuTTS voice-cloning TTS — GGUF backbone + NeuCodec decoder. # 0.1.0: backbone is llama-cpp-4. This means neutts @@ -121,13 +121,13 @@ kittentts = { version = "0.4.1", features = ["espeak"], optional = true } # sentence on Apple Silicon — effectively broken from a UX standpoint. # Declared here for non-macOS; the macOS entry is in # [target.'cfg(target_os = "macos")'.dependencies] below with `metal`. -neutts = { version = "0.1.1", features = ["backbone", "espeak", "fast", "wgpu"], optional = true } +neutts = { path = "../crates/skill-neutts", package = "skill-neutts", features = ["espeak"], optional = true } # Audio playback — pulled in automatically via tts-kitten / tts-neutts features. rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-wav"], optional = true } -# SHA-256 for NeuTTS WAV cache keys — already compiled transitively via neutts. -sha2 = { version = "0.10", optional = true } +# SHA-256: NeuTTS WAV cache keys + daemon binary identity for upgrade flow. +sha2 = "0.10" # WAV read-back for NeuTTS cache playback — already compiled transitively via neutts. hound = { version = "3", optional = true } @@ -164,7 +164,7 @@ skill-autostart = { path = "../crates/skill-autostart" } skill-calendar = { path = "../crates/skill-calendar" } skill-location = { path = "../crates/skill-location" } skill-constants = { path = "../crates/skill-constants" } -skill-label-index = { path = "../crates/skill-label-index" } +skill-label-index = { path = "../crates/skill-label-index", features = ["turboquant-index"] } # skill-screenshots removed — screenshot capture/OCR/embedding runs in skill-daemon only. skill-daemon-common = { path = "../crates/skill-daemon-common" } skill-skills = { path = "../crates/skill-skills", features = ["sync"] } @@ -200,7 +200,14 @@ dispatch2 = "0.3.1" # `metal` passes llama-cpp-2/metal which enables the Metal compute backend. # Without this the backbone falls back to CPU and a single utterance can take # 30–120 s, making TTS appear broken. -neutts = { version = "0.1.1", features = ["backbone", "espeak", "fast", "wgpu", "metal"], optional = true } +neutts = { path = "../crates/skill-neutts", package = "skill-neutts", features = ["espeak", "metal"], optional = true } + +# KittenTTS lives behind a non-Windows target gate so Windows builds never pull +# `ort` / onnxruntime. The `tts-kitten` feature stays in `default` for the cross- +# platform UX; on Windows it activates the `dep:kittentts` reference into a +# missing dep, which Cargo treats as a no-op (target-conditional optional dep). +[target.'cfg(not(target_os = "windows"))'.dependencies] +kittentts = { version = "0.4.1", features = ["espeak"], optional = true } [dev-dependencies] tempfile = "3" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index f53bb2cf..faeaab57 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -7,6 +7,7 @@ fn main() { emit_build_info(); + emit_tts_kitten_cfg(); // ── macOS / Linux: increase main-thread stack size (binary only) ───── // @@ -59,6 +60,21 @@ fn main() { // version. CI shallow checkouts work fine because the workflows already do // fetch-depth: 0. +// `tts-kitten` is a portable feature flag, but the `kittentts` dep itself is +// target-conditional (Windows skips it — see kittentts dep in src-tauri/Cargo.toml +// and crates/skill-tts/Cargo.toml). Use this derived cfg in source code instead +// of bare `cfg(feature = "tts-kitten")` so Windows skips the kitten code path +// even when the feature is enabled by `default`. +fn emit_tts_kitten_cfg() { + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_TTS_KITTEN"); + println!("cargo::rustc-check-cfg=cfg(tts_kitten_active)"); + let feat_on = std::env::var_os("CARGO_FEATURE_TTS_KITTEN").is_some(); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if feat_on && target_os != "windows" { + println!("cargo:rustc-cfg=tts_kitten_active"); + } +} + fn emit_build_info() { // Re-run when the current ref changes so cached builds pick up new commits. println!("cargo:rerun-if-changed=../.git/HEAD"); diff --git a/src-tauri/exg_catalog.json b/src-tauri/exg_catalog.json index 571393d9..9ba7f306 100644 --- a/src-tauri/exg_catalog.json +++ b/src-tauri/exg_catalog.json @@ -331,6 +331,54 @@ } } }, + "eegdino-small": { + "name": "EEG-DINO Small", + "description": "EEG-DINO foundation model (4.6M params, d_model=200). Hierarchical self-distillation on 9 000+ hours of EEG. RLX inference via eegdino-rs.", + "repo": "eugenehp/eegdino", + "tags": [ + "eeg", + "embedding", + "small" + ], + "weights_file": "eeg_dino_small.safetensors", + "config_file": null, + "params_m": 4.6, + "embed_dim": 200, + "paper": "https://arxiv.org/abs/2503.06669", + "doi": null + }, + "eegdino-medium": { + "name": "EEG-DINO Medium", + "description": "EEG-DINO foundation model (33M params, d_model=512). Medium variant for higher-quality embeddings.", + "repo": "eugenehp/eegdino", + "tags": [ + "eeg", + "embedding", + "medium" + ], + "weights_file": "eeg_dino_medium.safetensors", + "config_file": null, + "params_m": 33, + "embed_dim": 512, + "paper": "https://arxiv.org/abs/2503.06669", + "doi": null + }, + "eegdino-large": { + "name": "EEG-DINO Large", + "description": "EEG-DINO foundation model (201M params, d_model=1024). Largest variant; needs substantial VRAM on GPU backends.", + "repo": "eugenehp/eegdino", + "tags": [ + "eeg", + "embedding", + "large" + ], + "weights_file": "eeg_dino_large.safetensors", + "config_file": null, + "params_m": 201, + "embed_dim": 1024, + "paper": "https://arxiv.org/abs/2503.06669", + "doi": null + }, "neurorvq": { "name": "NeuroRVQ", "description": "NeuroRVQ biosignal tokenizer — residual vector quantization for EEG, ECG, and EMG. 4-branch encoder with patch-based FFT + RVQ codebooks. Modality-specific weights for tokenization and foundation model encoding. Burn 0.20 backend.", @@ -609,6 +657,25 @@ "description": "OpenTSLM — ~5M trainable params, 128-dim encoder + frozen Qwen3 LLM backbone", "recommended": true }, + { + "family": "eegdino-small", + "filename": "eeg_dino_small.safetensors", + "size_mb": 17, + "description": "EEG-DINO Small — 4.6M params, d_model=200, RLX inference", + "recommended": true + }, + { + "family": "eegdino-medium", + "filename": "eeg_dino_medium.safetensors", + "size_mb": 129, + "description": "EEG-DINO Medium — 33M params, d_model=512" + }, + { + "family": "eegdino-large", + "filename": "eeg_dino_large.safetensors", + "size_mb": 770, + "description": "EEG-DINO Large — 201M params, d_model=1024" + }, { "family": "neurorvq", "component": "eeg-tokenizer", diff --git a/src-tauri/hooks/post-update.cjs b/src-tauri/hooks/post-update.cjs index dcfc0f05..006f5ee7 100644 --- a/src-tauri/hooks/post-update.cjs +++ b/src-tauri/hooks/post-update.cjs @@ -1,55 +1,48 @@ -// Cross-platform post-update hook to restart the skill-daemon -const { execSync } = require('child_process'); +// Post-update hook: after the updater replaces the app bundle, mark the +// upgrade state as `pending` so the next app launch re-runs the failsafe +// upgrade flow (src-tauri/src/daemon_upgrade.rs) cleanly. We deliberately do +// NOT spawn the daemon here — the app does that on launch with full state +// tracking, hash verification, and rollback support. + const { platform } = require('os'); const path = require('path'); const fs = require('fs'); -console.log('[post-update] Restarting skill-daemon...'); +const home = process.env.HOME || process.env.USERPROFILE || ''; + +function configRoot() { + if (platform() === 'darwin') return path.join(home, 'Library/Application Support/skill/daemon'); + if (platform() === 'win32') return path.join(process.env.APPDATA || home, 'skill', 'daemon'); + return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'skill', 'daemon'); +} + +console.log('[post-update] preparing failsafe upgrade flow...'); try { - if (platform() === 'darwin') { - // macOS: Load LaunchAgent - const home = process.env.HOME || (process.env.USERPROFILE && process.env.USERPROFILE.replace(/\\/g, '/')); - const plistDest = path.join(home, 'Library/LaunchAgents/com.skill.daemon.plist'); - - // Copy plist from app bundle if not exists - const appResources = path.join( - path.dirname(process.execPath), - '..', - 'Resources', - 'com.skill.daemon.plist' - ); - - if (fs.existsSync(appResources) && !fs.existsSync(plistDest)) { - const launchAgentsDir = path.join(home, 'Library/LaunchAgents'); - if (!fs.existsSync(launchAgentsDir)) { - fs.mkdirSync(launchAgentsDir, { recursive: true }); - } - fs.copyFileSync(appResources, plistDest); - console.log('[post-update] Copied LaunchAgent plist to ' + plistDest); - } - - // Load the LaunchAgent (ignore errors if already loaded) - try { - execSync('launchctl load -w ' + plistDest); - console.log('[post-update] Loaded macOS LaunchAgent'); - } catch (loadError) { - console.log('[post-update] LaunchAgent already loaded or failed to load:', loadError.message); - } - } else if (platform() === 'win32') { - // Windows: Start the service - const daemonPath = path.join(path.dirname(process.execPath), 'skill-daemon.exe'); - if (fs.existsSync(daemonPath)) { - execSync('sc start skill-daemon 2>nul || start "" "' + daemonPath + '"', { shell: true }); - console.log('[post-update] Started Windows service'); - } - } else { - // Linux: Start systemd service - execSync('systemctl --user restart skill-daemon 2>/dev/null || nohup skill-daemon >/dev/null 2>&1 &', { shell: '/bin/bash' }); - console.log('[post-update] Started Linux service'); + const root = configRoot(); + fs.mkdirSync(root, { recursive: true }); + const statePath = path.join(root, 'state.json'); + + // Load current state (may be missing on fresh installs). + let state = {}; + try { + state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + } catch { + state = { version: 1, phase: 'ready' }; } + + // Force a re-check on next launch by clearing the installed_hash so the + // hash comparison fails and the state machine enters Upgrading. + state.installed_hash = null; + state.phase = 'upgrading'; + state.attempt_count = 0; + state.last_error = null; + state.updated_at = new Date().toISOString(); + + fs.writeFileSync(statePath + '.tmp', JSON.stringify(state, null, 2)); + fs.renameSync(statePath + '.tmp', statePath); + + console.log('[post-update] state.json marked for upgrade — next app launch will reconcile.'); } catch (error) { - console.error('[post-update] Failed to restart daemon:', error.message); + console.error('[post-update] failed to prep state:', error.message); } - -console.log('[post-update] Daemon restart triggered.'); diff --git a/src-tauri/hooks/pre-update.cjs b/src-tauri/hooks/pre-update.cjs index cf67d5af..c128c739 100644 --- a/src-tauri/hooks/pre-update.cjs +++ b/src-tauri/hooks/pre-update.cjs @@ -1,25 +1,79 @@ -// Cross-platform pre-update hook to stop the skill-daemon +// Pre-update hook: stop the running skill-daemon so the updater can replace +// the binary in place. Companion to the failsafe upgrade flow in +// src-tauri/src/daemon_upgrade.rs — kill by pidfile (precise) and unload the +// OS service WITHOUT `-w` so the plist stays enabled and the next launch can +// re-bootstrap it. + const { execSync } = require('child_process'); const { platform } = require('os'); +const path = require('path'); +const fs = require('fs'); + +const home = process.env.HOME || process.env.USERPROFILE || ''; + +function configRoot() { + if (platform() === 'darwin') return path.join(home, 'Library/Application Support/skill/daemon'); + if (platform() === 'win32') return path.join(process.env.APPDATA || home, 'skill', 'daemon'); + return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'skill', 'daemon'); +} + +function readPid() { + try { + const txt = fs.readFileSync(path.join(configRoot(), 'daemon.pid'), 'utf8').trim(); + const pid = parseInt(txt, 10); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function killByPid(pid) { + if (!pid) return false; + try { + if (platform() === 'win32') { + execSync(`taskkill /PID ${pid} /T`, { stdio: 'ignore' }); + } else { + // SIGTERM, then SIGKILL after a short grace. + try { process.kill(pid, 'SIGTERM'); } catch {} + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + try { process.kill(pid, 0); } catch { return true; } + execSync('sleep 0.1'); + } + try { process.kill(pid, 'SIGKILL'); } catch {} + } + return true; + } catch { + return false; + } +} -console.log('[pre-update] Stopping skill-daemon...'); +console.log('[pre-update] stopping skill-daemon...'); try { if (platform() === 'darwin') { - // macOS: Unload LaunchAgent - execSync('launchctl unload ~/Library/LaunchAgents/com.skill.daemon.plist 2>/dev/null || true'); - console.log('[pre-update] Stopped macOS LaunchAgent'); + const plist = path.join(home, 'Library/LaunchAgents/com.skill.daemon.plist'); + if (fs.existsSync(plist)) { + // bootout cleanly stops the service without disabling the plist + // (which `unload -w` would do, breaking next-boot autostart). + try { + const uid = execSync('id -u').toString().trim(); + execSync(`launchctl bootout gui/${uid} ${plist} 2>/dev/null || launchctl unload ${plist} 2>/dev/null || true`); + } catch {} + } + killByPid(readPid()); + console.log('[pre-update] macOS: launchd unloaded, daemon stopped.'); } else if (platform() === 'win32') { - // Windows: Stop the service - execSync('sc stop skill-daemon 2>nul || net stop skill-daemon 2>nul || taskkill /F /IM skill-daemon.exe 2>nul', { shell: true }); - console.log('[pre-update] Stopped Windows service'); + try { execSync('sc stop skill-daemon', { stdio: 'ignore' }); } catch {} + killByPid(readPid()); + console.log('[pre-update] Windows: service stopped, daemon killed.'); } else { - // Linux: Stop systemd service - execSync('systemctl --user stop skill-daemon 2>/dev/null || pkill -f skill-daemon 2>/dev/null || true', { shell: '/bin/bash' }); - console.log('[pre-update] Stopped Linux service'); + try { execSync('systemctl --user stop skill-daemon.service', { stdio: 'ignore' }); } catch {} + killByPid(readPid()); + console.log('[pre-update] Linux: systemd stopped, daemon killed.'); } } catch (error) { - console.error('[pre-update] Failed to stop daemon:', error.message); + console.error('[pre-update] error stopping daemon:', error.message); } -console.log('[pre-update] Daemon stopped (or not running).'); +console.log('[pre-update] done.'); diff --git a/src-tauri/llm_catalog.json b/src-tauri/llm_catalog.json index 4e605321..e0e9b120 100644 --- a/src-tauri/llm_catalog.json +++ b/src-tauri/llm_catalog.json @@ -12,7 +12,8 @@ "small" ], "is_mmproj": false, - "params_b": 4.0, + "mtp": false, + "params_b": 4, "max_context_length": 131072 }, "qwen35-9b": { @@ -25,7 +26,8 @@ "small" ], "is_mmproj": false, - "params_b": 9.0, + "mtp": false, + "params_b": 9, "max_context_length": 131072 }, "qwen251-coder-7b-instruct": { @@ -38,6 +40,7 @@ "medium" ], "is_mmproj": false, + "mtp": false, "params_b": 7.6, "max_context_length": 131072 }, @@ -51,7 +54,8 @@ "large" ], "is_mmproj": false, - "params_b": 27.0, + "mtp": false, + "params_b": 27, "max_context_length": 131072 }, "qwen35-27b-claude-opus-distilled": { @@ -64,61 +68,8 @@ "large" ], "is_mmproj": false, - "params_b": 27.0, - "max_context_length": 131072 - }, - "qwen36-27b": { - "name": "Qwen3.6 27B", - "description": "Alibaba's Qwen3.6 dense 27-billion-parameter model with vision support. Strong reasoning and multimodal capabilities; needs ≥ 16 GB VRAM for Q4.", - "repo": "unsloth/Qwen3.6-27B-GGUF", - "tags": [ - "chat", - "reasoning", - "vision", - "large" - ], - "is_mmproj": false, - "params_b": 27.0, - "max_context_length": 131072 - }, - "qwen36-35b-a3b": { - "name": "Qwen3.6 35B-A3B", - "description": "Alibaba's Qwen3.6 MoE model (35B total / ~3B active). High quality with low active compute; fits comfortably in 16 GB VRAM at Q4.", - "repo": "unsloth/Qwen3.6-35B-A3B-GGUF", - "tags": [ - "chat", - "reasoning", - "moe", - "large" - ], - "is_mmproj": false, - "params_b": 35.0, - "max_context_length": 131072 - }, - "glm-51": { - "name": "GLM 5.1", - "description": "Zhipu AI's GLM 5.1 chat model in GGUF format. Strong bilingual (Chinese/English) reasoning capabilities.", - "repo": "bartowski/zai-org_GLM-5.1-GGUF", - "tags": [ - "chat", - "reasoning", - "large" - ], - "is_mmproj": false, - "params_b": 9.0, - "max_context_length": 131072 - }, - "minimax-m27": { - "name": "MiniMax M2.7", - "description": "MiniMax's M2.7 chat model in GGUF format. Strong multilingual reasoning from MiniMaxAI.", - "repo": "bartowski/MiniMaxAI_MiniMax-M2.7-GGUF", - "tags": [ - "chat", - "reasoning", - "large" - ], - "is_mmproj": false, - "params_b": 27.0, + "mtp": false, + "params_b": 27, "max_context_length": 131072 }, "gpt-oss-20b": { @@ -131,7 +82,8 @@ "large" ], "is_mmproj": false, - "params_b": 20.0, + "mtp": false, + "params_b": 20, "max_context_length": 131072 }, "qwen3-coder-next": { @@ -144,7 +96,8 @@ "large" ], "is_mmproj": false, - "params_b": 14.0, + "mtp": false, + "params_b": 14, "max_context_length": 65536 }, "qwen3-vl-30b": { @@ -158,6 +111,7 @@ "large" ], "is_mmproj": false, + "mtp": false, "params_b": 32.8, "max_context_length": 131072 }, @@ -170,7 +124,8 @@ "medium" ], "is_mmproj": false, - "params_b": 14.0, + "mtp": false, + "params_b": 14, "max_context_length": 128000 }, "ministral-14b-reasoning": { @@ -182,7 +137,8 @@ "medium" ], "is_mmproj": false, - "params_b": 14.0, + "mtp": false, + "params_b": 14, "max_context_length": 128000 }, "gemma3-270m": { @@ -194,6 +150,7 @@ "tiny" ], "is_mmproj": false, + "mtp": false, "params_b": 0.27, "max_context_length": 32768 }, @@ -207,7 +164,8 @@ "large" ], "is_mmproj": false, - "params_b": 31.0, + "mtp": false, + "params_b": 31, "max_context_length": 131072 }, "gemma4-26b-a4b": { @@ -221,7 +179,8 @@ "large" ], "is_mmproj": false, - "params_b": 26.0, + "mtp": false, + "params_b": 26, "max_context_length": 131072 }, "gemma4-e4b": { @@ -234,7 +193,8 @@ "small" ], "is_mmproj": false, - "params_b": 4.0, + "mtp": false, + "params_b": 4, "max_context_length": 131072 }, "gemma4-e2b": { @@ -247,7 +207,8 @@ "tiny" ], "is_mmproj": false, - "params_b": 2.0, + "mtp": false, + "params_b": 2, "max_context_length": 131072 }, "phi4-reasoning-plus": { @@ -259,6 +220,7 @@ "medium" ], "is_mmproj": false, + "mtp": false, "params_b": 14.7, "max_context_length": 32768 }, @@ -272,7 +234,8 @@ "medium" ], "is_mmproj": false, - "params_b": 9.0, + "mtp": false, + "params_b": 9, "max_context_length": 32768 }, "qwen35-4b-hauhau-aggressive": { @@ -285,7 +248,8 @@ "small" ], "is_mmproj": false, - "params_b": 4.0, + "mtp": false, + "params_b": 4, "max_context_length": 131072 }, "qwen35-9b-hauhau-aggressive": { @@ -298,7 +262,8 @@ "small" ], "is_mmproj": false, - "params_b": 9.0, + "mtp": false, + "params_b": 9, "max_context_length": 131072 }, "qwen35-27b-hauhau-aggressive": { @@ -311,7 +276,8 @@ "large" ], "is_mmproj": false, - "params_b": 27.0, + "mtp": false, + "params_b": 27, "max_context_length": 131072 }, "qwen35-35b-a3b-hauhau-aggressive": { @@ -324,7 +290,8 @@ "large" ], "is_mmproj": false, - "params_b": 35.0, + "mtp": false, + "params_b": 35, "max_context_length": 131072 }, "qwen3-30b-a3b-instruct": { @@ -338,7 +305,8 @@ "large" ], "is_mmproj": false, - "params_b": 30.0, + "mtp": false, + "params_b": 30, "max_context_length": 131072 }, "lfm25-vl-1.6b": { @@ -352,6 +320,7 @@ "small" ], "is_mmproj": false, + "mtp": false, "params_b": 1.6, "max_context_length": 131072 }, @@ -364,6 +333,7 @@ "small" ], "is_mmproj": false, + "mtp": false, "params_b": 1.2, "max_context_length": 131072 }, @@ -377,6 +347,7 @@ "small" ], "is_mmproj": false, + "mtp": false, "params_b": 1.2, "max_context_length": 131072 }, @@ -391,7 +362,8 @@ "large" ], "is_mmproj": false, - "params_b": 456.0, + "mtp": false, + "params_b": 456, "max_context_length": 131072 }, "bonsai-8b": { @@ -404,6 +376,7 @@ "small" ], "is_mmproj": false, + "mtp": false, "params_b": 8.19, "max_context_length": 65536 }, @@ -417,6 +390,7 @@ "small" ], "is_mmproj": false, + "mtp": false, "params_b": 4.02, "max_context_length": 32768 }, @@ -430,6 +404,7 @@ "tiny" ], "is_mmproj": false, + "mtp": false, "params_b": 1.72, "max_context_length": 32768 }, @@ -443,8 +418,83 @@ "small" ], "is_mmproj": false, - "params_b": 4.0, + "mtp": false, + "params_b": 4, + "max_context_length": 131072 + }, + "minicpm5-1b": { + "name": "MiniCPM5 1B", + "description": "OpenBMB's compact 1B edge model (Llama-shaped). Strong chat quality for its size with 128K context. Ideal for low-VRAM devices and fast local inference via RLX.", + "repo": "openbmb/MiniCPM5-1B-GGUF", + "tags": [ + "chat", + "small", + "edge" + ], + "is_mmproj": false, + "mtp": false, + "params_b": 1, + "max_context_length": 131072 + }, + "qwen36-27b": { + "name": "Qwen3.6 27B", + "description": "Alibaba's Qwen3.6 dense 27-billion-parameter model with vision support. Strong reasoning and multimodal capabilities; needs ≥ 16 GB VRAM for Q4.", + "repo": "unsloth/Qwen3.6-27B-GGUF", + "tags": [ + "chat", + "reasoning", + "vision", + "large" + ], + "is_mmproj": false, + "mtp": false, + "params_b": 27, + "max_context_length": 131072 + }, + "qwen36-35b-a3b": { + "name": "Qwen3.6 35B-A3B", + "description": "Alibaba's Qwen3.6 MoE model (35B total / ~3B active). High quality with low active compute; fits comfortably in 16 GB VRAM at Q4.", + "repo": "unsloth/Qwen3.6-35B-A3B-GGUF", + "tags": [ + "chat", + "reasoning", + "moe", + "large" + ], + "is_mmproj": false, + "mtp": false, + "params_b": 35, + "max_context_length": 131072 + }, + "mistral-medium-3.5-128b": { + "name": "Mistral Medium 3.5 128B", + "description": "Mistral AI's 128B parameter medium-class model with strong reasoning and chat quality. Requires 64 GB+ unified memory or VRAM for Q4 quants.", + "repo": "bartowski/mistralai_Mistral-Medium-3.5-128B-GGUF", + "tags": [ + "chat", + "reasoning", + "large" + ], + "is_mmproj": false, + "mtp": false, + "params_b": 128, "max_context_length": 131072 + }, + "qwen36-27b-mtp": { + "name": "Qwen3.6 27B MTP", + "description": "Unsloth's Qwen3.6 27B GGUF builds with multi-token prediction (MTP) metadata enabled. Use with an MTP-capable runtime for speculative drafting and higher throughput.", + "repo": "unsloth/Qwen3.6-27B-MTP-GGUF", + "tags": [ + "chat", + "reasoning", + "vision", + "large", + "mtp" + ], + "is_mmproj": false, + "mtp": true, + "params_b": 27, + "max_context_length": 262144 } }, "models": [ @@ -1403,6 +1453,54 @@ "description": "Full FP16 precision; ≥ 64 GB VRAM", "advanced": true }, + { + "family": "qwen35-27b-claude-opus-distilled", + "filename": "Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-F32.gguf", + "quant": "F32", + "size_gb": 9.98, + "description": "F32 quant", + "advanced": true + }, + { + "family": "qwen35-27b-claude-opus-distilled", + "filename": "Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-IQ3_S.gguf", + "quant": "IQ3_S", + "size_gb": 9.98, + "description": "IQ3_S quant", + "advanced": true + }, + { + "family": "qwen35-27b-claude-opus-distilled", + "filename": "Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-Q5_0.gguf", + "quant": "Q5_0", + "size_gb": 9.98, + "description": "Q5_0 quant", + "advanced": true + }, + { + "family": "qwen35-27b-claude-opus-distilled", + "filename": "Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-Q5_1.gguf", + "quant": "Q5_1", + "size_gb": 9.98, + "description": "Q5_1 quant", + "advanced": true + }, + { + "family": "qwen35-27b-claude-opus-distilled", + "filename": "Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-TQ1_0.gguf", + "quant": "UNKNOWN", + "size_gb": 9.98, + "description": "UNKNOWN quant", + "advanced": true + }, + { + "family": "qwen35-27b-claude-opus-distilled", + "filename": "Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-TQ2_0.gguf", + "quant": "UNKNOWN", + "size_gb": 9.98, + "description": "UNKNOWN quant", + "advanced": true + }, { "family": "gpt-oss-20b", "filename": "gpt-oss-20b-Q2_K.gguf", @@ -2638,110 +2736,771 @@ "advanced": true }, { - "family": "gemma4-26b-a4b", - "filename": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_gb": 15.2, - "description": "Recommended — best quality/size tradeoff", - "recommended": true + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-UD-IQ2_XXS.gguf", + "quant": "IQ2_XXS", + "size_gb": 17.8, + "description": "IQ2_XXS quant", + "advanced": true }, { - "family": "gemma4-26b-a4b", - "filename": "gemma-4-26B-A4B-it-UD-Q6_K.gguf", - "quant": "Q6_K", - "size_gb": 20.1, - "description": "Near-lossless quality", + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-UD-IQ2_M.gguf", + "quant": "IQ2_M", + "size_gb": 17.8, + "description": "IQ2_M quant", "advanced": true }, { - "family": "gemma4-26b-a4b", - "filename": "gemma-4-26B-A4B-it-Q8_0.gguf", - "quant": "Q8_0", - "size_gb": 25.8, - "description": "Effectively lossless; very large", + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-UD-IQ3_XXS.gguf", + "quant": "IQ3_XXS", + "size_gb": 17.8, + "description": "IQ3_XXS quant", "advanced": true }, { - "family": "gemma4-e4b", - "filename": "gemma-4-E4B-it-Q4_0.gguf", - "quant": "Q4_0", - "size_gb": 2.9, - "description": "Legacy 4-bit quant; broad compatibility" + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-Q3_K_S.gguf", + "quant": "Q3_K_S", + "size_gb": 17.8, + "description": "Q3_K_S quant", + "advanced": true }, { - "family": "gemma4-e4b", - "filename": "gemma-4-E4B-it-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_gb": 3.2, - "description": "Recommended — best quality/size tradeoff", - "recommended": true + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-Q3_K_M.gguf", + "quant": "Q3_K_M", + "size_gb": 17.8, + "description": "Q3_K_M quant", + "advanced": true }, { - "family": "gemma4-e4b", - "filename": "gemma-4-E4B-it-Q6_K.gguf", - "quant": "Q6_K", - "size_gb": 4.2, - "description": "Near-lossless quality", + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-UD-Q3_K_XL.gguf", + "quant": "Q3_K_XL", + "size_gb": 17.8, + "description": "Q3_K_XL quant", "advanced": true }, { - "family": "gemma4-e4b", - "filename": "gemma-4-E4B-it-Q8_0.gguf", - "quant": "Q8_0", - "size_gb": 5.5, - "description": "Effectively lossless", + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-IQ4_XS.gguf", + "quant": "IQ4_XS", + "size_gb": 17.8, + "description": "IQ4_XS quant", "advanced": true }, { - "family": "gemma4-e2b", - "filename": "gemma-4-E2B-it-Q4_0.gguf", - "quant": "Q4_0", - "size_gb": 1.7, - "description": "Legacy 4-bit quant; broad compatibility" + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-IQ4_NL.gguf", + "quant": "IQ4_NL", + "size_gb": 17.8, + "description": "IQ4_NL quant", + "advanced": true }, { - "family": "gemma4-e2b", - "filename": "gemma-4-E2B-it-Q4_K_M.gguf", - "quant": "Q4_K_M", - "size_gb": 1.9, - "description": "Recommended — best quality/size tradeoff", - "recommended": true + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-Q4_1.gguf", + "quant": "Q4_1", + "size_gb": 17.8, + "description": "Q4_1 quant" }, { - "family": "gemma4-e2b", - "filename": "gemma-4-E2B-it-Q6_K.gguf", - "quant": "Q6_K", - "size_gb": 2.4, - "description": "Near-lossless quality", - "advanced": true + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-Q4_K_S.gguf", + "quant": "Q4_K_S", + "size_gb": 17.8, + "description": "Q4_K_S quant" }, { - "family": "gemma4-e2b", - "filename": "gemma-4-E2B-it-Q8_0.gguf", - "quant": "Q8_0", - "size_gb": 3.2, - "description": "Effectively lossless", + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-Q5_K_S.gguf", + "quant": "Q5_K_S", + "size_gb": 17.8, + "description": "Q5_K_S quant", "advanced": true }, { - "family": "phi4-reasoning-plus", - "filename": "Phi-4-reasoning-plus-Q2_K.gguf", - "quant": "Q2_K", - "size_gb": 4.9, - "description": "Ultra-compressed; 8 GB VRAM", + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_gb": 17.8, + "description": "Q5_K_M quant", "advanced": true }, { - "family": "phi4-reasoning-plus", - "filename": "Phi-4-reasoning-plus-Q4_0.gguf", - "quant": "Q4_0", - "size_gb": 7.8, - "description": "Recommended — 12 GB VRAM", - "recommended": true + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-UD-Q2_K_XL.gguf", + "quant": "Q2_K_XL", + "size_gb": 17.8, + "description": "Q2_K_XL quant", + "advanced": true }, { - "family": "phi4-reasoning-plus", - "filename": "Phi-4-reasoning-plus-Q4_K_M.gguf", + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-UD-Q4_K_XL.gguf", + "quant": "Q4_K_XL", + "size_gb": 17.8, + "description": "Q4_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-UD-Q5_K_XL.gguf", + "quant": "Q5_K_XL", + "size_gb": 17.8, + "description": "Q5_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-UD-Q6_K_XL.gguf", + "quant": "Q6_K_XL", + "size_gb": 17.8, + "description": "Q6_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-31b", + "filename": "gemma-4-31B-it-UD-Q8_K_XL.gguf", + "quant": "Q8_K_XL", + "size_gb": 17.8, + "description": "Q8_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-31b", + "filename": "mtp-gemma-4-31B-it.gguf", + "quant": "UNKNOWN", + "size_gb": 17.8, + "description": "UNKNOWN quant", + "advanced": true + }, + { + "family": "gemma4-31b", + "filename": "mmproj-BF16.gguf", + "quant": "BF16", + "size_gb": 17.8, + "description": "Vision projector — BF16 (recommended)", + "recommended": true + }, + { + "family": "gemma4-31b", + "filename": "mmproj-F16.gguf", + "quant": "F16", + "size_gb": 17.8, + "description": "Vision projector — FP16" + }, + { + "family": "gemma4-31b", + "filename": "mmproj-F32.gguf", + "quant": "F32", + "size_gb": 17.8, + "description": "Vision projector — FP32", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 15.2, + "description": "Recommended — best quality/size tradeoff", + "recommended": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q6_K.gguf", + "quant": "Q6_K", + "size_gb": 20.1, + "description": "Near-lossless quality", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 25.8, + "description": "Effectively lossless; very large", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-IQ2_XXS.gguf", + "quant": "IQ2_XXS", + "size_gb": 15.2, + "description": "IQ2_XXS quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-IQ2_M.gguf", + "quant": "IQ2_M", + "size_gb": 15.2, + "description": "IQ2_M quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-IQ3_XXS.gguf", + "quant": "IQ3_XXS", + "size_gb": 15.2, + "description": "IQ3_XXS quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q3_K_M.gguf", + "quant": "Q3_K_M", + "size_gb": 15.2, + "description": "Q3_K_M quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q3_K_XL.gguf", + "quant": "Q3_K_XL", + "size_gb": 15.2, + "description": "Q3_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-IQ4_XS.gguf", + "quant": "IQ4_XS", + "size_gb": 15.2, + "description": "IQ4_XS quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-IQ4_NL.gguf", + "quant": "IQ4_NL", + "size_gb": 15.2, + "description": "IQ4_NL quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q4_K_S.gguf", + "quant": "Q4_K_S", + "size_gb": 15.2, + "description": "Q4_K_S quant" + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q5_K_S.gguf", + "quant": "Q5_K_S", + "size_gb": 15.2, + "description": "Q5_K_S quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_gb": 15.2, + "description": "Q5_K_M quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-MXFP4_MOE.gguf", + "quant": "UNKNOWN", + "size_gb": 15.2, + "description": "UNKNOWN quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-IQ3_S.gguf", + "quant": "IQ3_S", + "size_gb": 15.2, + "description": "IQ3_S quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q2_K_XL.gguf", + "quant": "Q2_K_XL", + "size_gb": 15.2, + "description": "Q2_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q4_K_XL.gguf", + "quant": "Q4_K_XL", + "size_gb": 15.2, + "description": "Q4_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf", + "quant": "Q5_K_XL", + "size_gb": 15.2, + "description": "Q5_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q6_K_XL.gguf", + "quant": "Q6_K_XL", + "size_gb": 15.2, + "description": "Q6_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "gemma-4-26B-A4B-it-UD-Q8_K_XL.gguf", + "quant": "Q8_K_XL", + "size_gb": 15.2, + "description": "Q8_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "mtp-gemma-4-26B-A4B-it.gguf", + "quant": "UNKNOWN", + "size_gb": 15.2, + "description": "UNKNOWN quant", + "advanced": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "mmproj-BF16.gguf", + "quant": "BF16", + "size_gb": 15.2, + "description": "Vision projector — BF16 (recommended)", + "recommended": true + }, + { + "family": "gemma4-26b-a4b", + "filename": "mmproj-F16.gguf", + "quant": "F16", + "size_gb": 15.2, + "description": "Vision projector — FP16" + }, + { + "family": "gemma4-26b-a4b", + "filename": "mmproj-F32.gguf", + "quant": "F32", + "size_gb": 15.2, + "description": "Vision projector — FP32", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q4_0.gguf", + "quant": "Q4_0", + "size_gb": 2.9, + "description": "Legacy 4-bit quant; broad compatibility" + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 3.2, + "description": "Recommended — best quality/size tradeoff", + "recommended": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q6_K.gguf", + "quant": "Q6_K", + "size_gb": 4.2, + "description": "Near-lossless quality", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 5.5, + "description": "Effectively lossless", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-UD-IQ2_M.gguf", + "quant": "IQ2_M", + "size_gb": 2.9, + "description": "IQ2_M quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-UD-IQ3_XXS.gguf", + "quant": "IQ3_XXS", + "size_gb": 2.9, + "description": "IQ3_XXS quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q3_K_S.gguf", + "quant": "Q3_K_S", + "size_gb": 2.9, + "description": "Q3_K_S quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q3_K_M.gguf", + "quant": "Q3_K_M", + "size_gb": 2.9, + "description": "Q3_K_M quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-UD-Q3_K_XL.gguf", + "quant": "Q3_K_XL", + "size_gb": 2.9, + "description": "Q3_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-IQ4_XS.gguf", + "quant": "IQ4_XS", + "size_gb": 2.9, + "description": "IQ4_XS quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-IQ4_NL.gguf", + "quant": "IQ4_NL", + "size_gb": 2.9, + "description": "IQ4_NL quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q4_1.gguf", + "quant": "Q4_1", + "size_gb": 2.9, + "description": "Q4_1 quant" + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q4_K_S.gguf", + "quant": "Q4_K_S", + "size_gb": 2.9, + "description": "Q4_K_S quant" + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q5_K_S.gguf", + "quant": "Q5_K_S", + "size_gb": 2.9, + "description": "Q5_K_S quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_gb": 2.9, + "description": "Q5_K_M quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-BF16.gguf", + "quant": "BF16", + "size_gb": 2.9, + "description": "Full precision weights", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-UD-Q2_K_XL.gguf", + "quant": "Q2_K_XL", + "size_gb": 2.9, + "description": "Q2_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-UD-Q4_K_XL.gguf", + "quant": "Q4_K_XL", + "size_gb": 2.9, + "description": "Q4_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-UD-Q5_K_XL.gguf", + "quant": "Q5_K_XL", + "size_gb": 2.9, + "description": "Q5_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-UD-Q6_K_XL.gguf", + "quant": "Q6_K_XL", + "size_gb": 2.9, + "description": "Q6_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "gemma-4-E4B-it-UD-Q8_K_XL.gguf", + "quant": "Q8_K_XL", + "size_gb": 2.9, + "description": "Q8_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "mtp-gemma-4-E4B-it.gguf", + "quant": "UNKNOWN", + "size_gb": 2.9, + "description": "UNKNOWN quant", + "advanced": true + }, + { + "family": "gemma4-e4b", + "filename": "mmproj-BF16.gguf", + "quant": "BF16", + "size_gb": 2.9, + "description": "Vision projector — BF16 (recommended)", + "recommended": true + }, + { + "family": "gemma4-e4b", + "filename": "mmproj-F16.gguf", + "quant": "F16", + "size_gb": 2.9, + "description": "Vision projector — FP16" + }, + { + "family": "gemma4-e4b", + "filename": "mmproj-F32.gguf", + "quant": "F32", + "size_gb": 2.9, + "description": "Vision projector — FP32", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q4_0.gguf", + "quant": "Q4_0", + "size_gb": 1.7, + "description": "Legacy 4-bit quant; broad compatibility" + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 1.9, + "description": "Recommended — best quality/size tradeoff", + "recommended": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q6_K.gguf", + "quant": "Q6_K", + "size_gb": 2.4, + "description": "Near-lossless quality", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 3.2, + "description": "Effectively lossless", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-UD-IQ2_M.gguf", + "quant": "IQ2_M", + "size_gb": 1.7, + "description": "IQ2_M quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-UD-IQ3_XXS.gguf", + "quant": "IQ3_XXS", + "size_gb": 1.7, + "description": "IQ3_XXS quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q3_K_S.gguf", + "quant": "Q3_K_S", + "size_gb": 1.7, + "description": "Q3_K_S quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q3_K_M.gguf", + "quant": "Q3_K_M", + "size_gb": 1.7, + "description": "Q3_K_M quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-UD-Q3_K_XL.gguf", + "quant": "Q3_K_XL", + "size_gb": 1.7, + "description": "Q3_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-IQ4_XS.gguf", + "quant": "IQ4_XS", + "size_gb": 1.7, + "description": "IQ4_XS quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-IQ4_NL.gguf", + "quant": "IQ4_NL", + "size_gb": 1.7, + "description": "IQ4_NL quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q4_1.gguf", + "quant": "Q4_1", + "size_gb": 1.7, + "description": "Q4_1 quant" + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q4_K_S.gguf", + "quant": "Q4_K_S", + "size_gb": 1.7, + "description": "Q4_K_S quant" + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q5_K_S.gguf", + "quant": "Q5_K_S", + "size_gb": 1.7, + "description": "Q5_K_S quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_gb": 1.7, + "description": "Q5_K_M quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-BF16.gguf", + "quant": "BF16", + "size_gb": 1.7, + "description": "Full precision weights", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-UD-Q2_K_XL.gguf", + "quant": "Q2_K_XL", + "size_gb": 1.7, + "description": "Q2_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-UD-Q4_K_XL.gguf", + "quant": "Q4_K_XL", + "size_gb": 1.7, + "description": "Q4_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-UD-Q5_K_XL.gguf", + "quant": "Q5_K_XL", + "size_gb": 1.7, + "description": "Q5_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-UD-Q6_K_XL.gguf", + "quant": "Q6_K_XL", + "size_gb": 1.7, + "description": "Q6_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "gemma-4-E2B-it-UD-Q8_K_XL.gguf", + "quant": "Q8_K_XL", + "size_gb": 1.7, + "description": "Q8_K_XL quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "mtp-gemma-4-E2B-it.gguf", + "quant": "UNKNOWN", + "size_gb": 1.7, + "description": "UNKNOWN quant", + "advanced": true + }, + { + "family": "gemma4-e2b", + "filename": "mmproj-BF16.gguf", + "quant": "BF16", + "size_gb": 1.7, + "description": "Vision projector — BF16 (recommended)", + "recommended": true + }, + { + "family": "gemma4-e2b", + "filename": "mmproj-F16.gguf", + "quant": "F16", + "size_gb": 1.7, + "description": "Vision projector — FP16" + }, + { + "family": "gemma4-e2b", + "filename": "mmproj-F32.gguf", + "quant": "F32", + "size_gb": 1.7, + "description": "Vision projector — FP32", + "advanced": true + }, + { + "family": "phi4-reasoning-plus", + "filename": "Phi-4-reasoning-plus-Q2_K.gguf", + "quant": "Q2_K", + "size_gb": 4.9, + "description": "Ultra-compressed; 8 GB VRAM", + "advanced": true + }, + { + "family": "phi4-reasoning-plus", + "filename": "Phi-4-reasoning-plus-Q4_0.gguf", + "quant": "Q4_0", + "size_gb": 7.8, + "description": "Recommended — 12 GB VRAM", + "recommended": true + }, + { + "family": "phi4-reasoning-plus", + "filename": "Phi-4-reasoning-plus-Q4_K_M.gguf", "quant": "Q4_K_M", "size_gb": 8.3, "description": "Better than Q4_0; same VRAM" @@ -3683,13 +4442,29 @@ "description": "1-bit quantization — 14× smaller than FP16, 6× faster on GPU", "recommended": true }, + { + "family": "bonsai-8b", + "filename": "Bonsai-8B-Q1_0.gguf", + "quant": "Q1_0", + "size_gb": 1.08, + "description": "Q1_0 quant", + "advanced": true + }, { "family": "bonsai-4b", "filename": "Bonsai-4B.gguf", "quant": "Q1_0_G128", "size_gb": 0.53, - "description": "1-bit quantization — fits in under 1 GB of memory", - "recommended": true + "description": "1-bit quantization — fits in under 1 GB of memory", + "recommended": true + }, + { + "family": "bonsai-4b", + "filename": "Bonsai-4B-Q1_0.gguf", + "quant": "Q1_0", + "size_gb": 0.53, + "description": "Q1_0 quant", + "advanced": true }, { "family": "bonsai-1.7b", @@ -3699,6 +4474,14 @@ "description": "1-bit quantization — 13.9× smaller than FP16, runs on any device", "recommended": true }, + { + "family": "bonsai-1.7b", + "filename": "Bonsai-1.7B-Q1_0.gguf", + "quant": "Q1_0", + "size_gb": 0.24, + "description": "Q1_0 quant", + "advanced": true + }, { "family": "nemotron-3-nano-4b", "filename": "NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf", @@ -3707,6 +4490,28 @@ "description": "Recommended — only available quant", "recommended": true }, + { + "family": "minicpm5-1b", + "filename": "MiniCPM5-1B-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 0.85, + "description": "Recommended — best quality/size balance for 1B edge chat", + "recommended": true + }, + { + "family": "minicpm5-1b", + "filename": "MiniCPM5-1B-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 1.3, + "description": "Higher fidelity 8-bit quant" + }, + { + "family": "minicpm5-1b", + "filename": "MiniCPM5-1B-F16.gguf", + "quant": "F16", + "size_gb": 2.1, + "description": "Full FP16 weights — highest quality, needs ~4 GB VRAM" + }, { "family": "qwen36-27b", "filename": "Qwen3.6-27B-UD-IQ2_XXS.gguf", @@ -4073,6 +4878,434 @@ "size_gb": 35.81, "description": "8-bit with important layers at higher precision", "advanced": true + }, + { + "family": "qwen36-35b-a3b", + "filename": "Qwen3.6-35B-A3B-UD-IQ4_NL_XL.gguf", + "quant": "IQ4_NL_XL", + "size_gb": 9.36, + "description": "IQ4_NL_XL quant", + "advanced": true + }, + { + "family": "qwen36-35b-a3b", + "filename": "mmproj-BF16.gguf", + "quant": "BF16", + "size_gb": 9.36, + "description": "Vision projector — BF16 (recommended)", + "recommended": true + }, + { + "family": "qwen36-35b-a3b", + "filename": "mmproj-F16.gguf", + "quant": "F16", + "size_gb": 9.36, + "description": "Vision projector — FP16" + }, + { + "family": "qwen36-35b-a3b", + "filename": "mmproj-F32.gguf", + "quant": "F32", + "size_gb": 9.36, + "description": "Vision projector — FP32", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q2_K.gguf", + "quant": "Q2_K", + "size_gb": 49.86, + "description": "Very low quality; smallest single-file download", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ2_M.gguf", + "quant": "IQ2_M", + "size_gb": 48.61, + "description": "Low quality imatrix; surprisingly usable", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q2_K_L/mistralai_Mistral-Medium-3.5-128B-Q2_K_L-00001-of-00002.gguf", + "quant": "Q2_K_L", + "size_gb": 51.43, + "description": "Q8_0 embed/output weights; very low quality", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q2_K_L/mistralai_Mistral-Medium-3.5-128B-Q2_K_L-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q2_K_L/mistralai_Mistral-Medium-3.5-128B-Q2_K_L-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ3_M/mistralai_Mistral-Medium-3.5-128B-IQ3_M-00001-of-00002.gguf", + "quant": "IQ3_M", + "size_gb": 59.53, + "description": "Medium-low quality imatrix; decent performance", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-IQ3_M/mistralai_Mistral-Medium-3.5-128B-IQ3_M-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-IQ3_M/mistralai_Mistral-Medium-3.5-128B-IQ3_M-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q3_K_M/mistralai_Mistral-Medium-3.5-128B-Q3_K_M-00001-of-00002.gguf", + "quant": "Q3_K_M", + "size_gb": 63.28, + "description": "Low quality; good for limited RAM/VRAM", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q3_K_M/mistralai_Mistral-Medium-3.5-128B-Q3_K_M-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q3_K_M/mistralai_Mistral-Medium-3.5-128B-Q3_K_M-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ4_XS/mistralai_Mistral-Medium-3.5-128B-IQ4_XS-00001-of-00002.gguf", + "quant": "IQ4_XS", + "size_gb": 69.14, + "description": "Decent quality imatrix; smaller than Q4_K_S", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-IQ4_XS/mistralai_Mistral-Medium-3.5-128B-IQ4_XS-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-IQ4_XS/mistralai_Mistral-Medium-3.5-128B-IQ4_XS-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q4_K_S/mistralai_Mistral-Medium-3.5-128B-Q4_K_S-00001-of-00002.gguf", + "quant": "Q4_K_S", + "size_gb": 73.02, + "description": "Good quality with space savings", + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q4_K_S/mistralai_Mistral-Medium-3.5-128B-Q4_K_S-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q4_K_S/mistralai_Mistral-Medium-3.5-128B-Q4_K_S-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q4_K_M/mistralai_Mistral-Medium-3.5-128B-Q4_K_M-00001-of-00002.gguf", + "quant": "Q4_K_M", + "size_gb": 78.41, + "description": "Recommended -- best quality/size tradeoff", + "recommended": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q4_K_M/mistralai_Mistral-Medium-3.5-128B-Q4_K_M-00001-of-00002.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q4_K_M/mistralai_Mistral-Medium-3.5-128B-Q4_K_M-00002-of-00002.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q5_K_M/mistralai_Mistral-Medium-3.5-128B-Q5_K_M-00001-of-00003.gguf", + "quant": "Q5_K_M", + "size_gb": 91.11, + "description": "High quality; >= 96 GB unified memory", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q5_K_M/mistralai_Mistral-Medium-3.5-128B-Q5_K_M-00001-of-00003.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q5_K_M/mistralai_Mistral-Medium-3.5-128B-Q5_K_M-00002-of-00003.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q5_K_M/mistralai_Mistral-Medium-3.5-128B-Q5_K_M-00003-of-00003.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q6_K/mistralai_Mistral-Medium-3.5-128B-Q6_K-00001-of-00003.gguf", + "quant": "Q6_K", + "size_gb": 107.8, + "description": "Very high quality, near perfect; >= 112 GB unified memory", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q6_K/mistralai_Mistral-Medium-3.5-128B-Q6_K-00001-of-00003.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q6_K/mistralai_Mistral-Medium-3.5-128B-Q6_K-00002-of-00003.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q6_K/mistralai_Mistral-Medium-3.5-128B-Q6_K-00003-of-00003.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00001-of-00004.gguf", + "quant": "Q8_0", + "size_gb": 132.85, + "description": "Effectively lossless; very large", + "advanced": true, + "shard_files": [ + "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00001-of-00004.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00002-of-00004.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00003-of-00004.gguf", + "mistralai_Mistral-Medium-3.5-128B-Q8_0/mistralai_Mistral-Medium-3.5-128B-Q8_0-00004-of-00004.gguf" + ] + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ1_S.gguf", + "quant": "IQ1_S", + "size_gb": 49.86, + "description": "IQ1_S quant", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ1_M.gguf", + "quant": "IQ1_M", + "size_gb": 49.86, + "description": "IQ1_M quant", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ2_XXS.gguf", + "quant": "IQ2_XXS", + "size_gb": 49.86, + "description": "IQ2_XXS quant", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ2_XS.gguf", + "quant": "IQ2_XS", + "size_gb": 49.86, + "description": "IQ2_XS quant", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mistralai_Mistral-Medium-3.5-128B-IQ2_S.gguf", + "quant": "IQ2_S", + "size_gb": 49.86, + "description": "IQ2_S quant", + "advanced": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mmproj-mistralai_Mistral-Medium-3.5-128B-bf16.gguf", + "quant": "BF16", + "size_gb": 49.86, + "description": "Vision projector — BF16 (recommended)", + "recommended": true + }, + { + "family": "mistral-medium-3.5-128b", + "filename": "mmproj-mistralai_Mistral-Medium-3.5-128B-f32.gguf", + "quant": "F32", + "size_gb": 49.86, + "description": "Vision projector — FP32", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-IQ2_XXS-mtp.gguf", + "quant": "IQ2_XXS", + "size_gb": 8.75, + "description": "Ultra-small 2-bit with MTP", + "remote_filename": "Qwen3.6-27B-UD-IQ2_XXS.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-IQ2_M-mtp.gguf", + "quant": "IQ2_M", + "size_gb": 9.74, + "description": "2-bit with MTP; fits 12 GB VRAM", + "remote_filename": "Qwen3.6-27B-UD-IQ2_M.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-IQ3_XXS-mtp.gguf", + "quant": "IQ3_XXS", + "size_gb": 11.18, + "description": "Compact 3-bit with MTP", + "remote_filename": "Qwen3.6-27B-UD-IQ3_XXS.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q3_K_S-mtp.gguf", + "quant": "Q3_K_S", + "size_gb": 11.51, + "description": "Small 3-bit with MTP; fits 16 GB VRAM", + "remote_filename": "Qwen3.6-27B-Q3_K_S.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q3_K_M-mtp.gguf", + "quant": "Q3_K_M", + "size_gb": 12.65, + "description": "Medium 3-bit with MTP", + "remote_filename": "Qwen3.6-27B-Q3_K_M.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q2_K_XL-mtp.gguf", + "quant": "Q2_K_XL", + "size_gb": 13.47, + "description": "2-bit with important layers at higher precision and MTP", + "remote_filename": "Qwen3.6-27B-UD-Q2_K_XL.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q3_K_XL-mtp.gguf", + "quant": "Q3_K_XL", + "size_gb": 13.48, + "description": "3-bit with important layers at higher precision and MTP", + "remote_filename": "Qwen3.6-27B-UD-Q3_K_XL.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-IQ4_XS-mtp.gguf", + "quant": "IQ4_XS", + "size_gb": 14.47, + "description": "Compact 4-bit with MTP", + "remote_filename": "Qwen3.6-27B-IQ4_XS.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q4_0-mtp.gguf", + "quant": "Q4_0", + "size_gb": 14.71, + "description": "4-bit with MTP; fits 16 GB VRAM", + "remote_filename": "Qwen3.6-27B-Q4_0.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q4_K_S-mtp.gguf", + "quant": "Q4_K_S", + "size_gb": 14.76, + "description": "4-bit k-quant small with MTP", + "remote_filename": "Qwen3.6-27B-Q4_K_S.gguf" + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-IQ4_NL-mtp.gguf", + "quant": "IQ4_NL", + "size_gb": 14.97, + "description": "4-bit non-linear with MTP; good quality/size balance", + "remote_filename": "Qwen3.6-27B-IQ4_NL.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q4_K_M-mtp.gguf", + "quant": "Q4_K_M", + "size_gb": 15.83, + "description": "Best quality/size balance with MTP; needs 16 GB VRAM or 24 GB RAM", + "remote_filename": "Qwen3.6-27B-Q4_K_M.gguf", + "recommended": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q4_1-mtp.gguf", + "quant": "Q4_1", + "size_gb": 16.07, + "description": "4-bit variant with MTP; slightly higher quality than Q4_0", + "remote_filename": "Qwen3.6-27B-Q4_1.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q4_K_XL-mtp.gguf", + "quant": "Q4_K_XL", + "size_gb": 16.4, + "description": "4-bit with important layers at higher precision and MTP", + "remote_filename": "Qwen3.6-27B-UD-Q4_K_XL.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q5_K_S-mtp.gguf", + "quant": "Q5_K_S", + "size_gb": 17.66, + "description": "5-bit with MTP; needs 24 GB VRAM", + "remote_filename": "Qwen3.6-27B-Q5_K_S.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q5_K_M-mtp.gguf", + "quant": "Q5_K_M", + "size_gb": 18.33, + "description": "Higher quality 5-bit with MTP; needs 24 GB VRAM", + "remote_filename": "Qwen3.6-27B-Q5_K_M.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q5_K_XL-mtp.gguf", + "quant": "Q5_K_XL", + "size_gb": 18.66, + "description": "5-bit with important layers at higher precision and MTP", + "remote_filename": "Qwen3.6-27B-UD-Q5_K_XL.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q6_K-mtp.gguf", + "quant": "Q6_K", + "size_gb": 20.99, + "description": "6-bit with MTP; high quality, needs 32 GB RAM", + "remote_filename": "Qwen3.6-27B-Q6_K.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q6_K_XL-mtp.gguf", + "quant": "Q6_K_XL", + "size_gb": 23.88, + "description": "6-bit with important layers at higher precision and MTP", + "remote_filename": "Qwen3.6-27B-UD-Q6_K_XL.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q8_0-mtp.gguf", + "quant": "Q8_0", + "size_gb": 27.05, + "description": "Near-lossless 8-bit with MTP; needs 48 GB RAM", + "remote_filename": "Qwen3.6-27B-Q8_0.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q8_K_XL-mtp.gguf", + "quant": "Q8_K_XL", + "size_gb": 32.89, + "description": "8-bit with important layers at higher precision and MTP", + "remote_filename": "Qwen3.6-27B-UD-Q8_K_XL.gguf", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "mmproj-Qwen3.6-27B-BF16-mtp.gguf", + "quant": "BF16", + "size_gb": 0.87, + "description": "Qwen3.6-27B MTP vision projector -- BF16 (recommended)", + "remote_filename": "mmproj-BF16.gguf", + "recommended": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "mmproj-Qwen3.6-27B-F16-mtp.gguf", + "quant": "F16", + "size_gb": 0.86, + "description": "Qwen3.6-27B MTP vision projector -- FP16", + "remote_filename": "mmproj-F16.gguf" + }, + { + "family": "qwen36-27b-mtp", + "filename": "mmproj-Qwen3.6-27B-F32-mtp.gguf", + "quant": "F32", + "size_gb": 1.72, + "description": "Qwen3.6-27B MTP vision projector -- FP32", + "remote_filename": "mmproj-F32.gguf", + "advanced": true } ] } diff --git a/src-tauri/src/auto_update.rs b/src-tauri/src/auto_update.rs new file mode 100644 index 00000000..16ffe1b8 --- /dev/null +++ b/src-tauri/src/auto_update.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +//! Auto-update opt-out preference. +//! +//! When enabled (the default), the frontend automatically downloads and +//! installs an update as soon as the background poller emits +//! `update-available`. When disabled, the same event surfaces a notice in +//! the Updates tab and the user must click "Install" to proceed. +//! +//! Storage mirrors `update_channel.rs`: a single ASCII line in +//! `/auto-update.txt` containing `true` or `false`. A +//! missing or unreadable file is treated as `true` so first-run users get +//! today's behavior. + +use std::path::PathBuf; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; + +use crate::state::AppState; +use crate::MutexExt; + +const PREF_FILE: &str = "auto-update.txt"; + +fn pref_path(app: &AppHandle) -> Option { + app.path() + .app_local_data_dir() + .ok() + .map(|d| d.join(PREF_FILE)) +} + +pub fn read_auto_update_enabled(app: &AppHandle) -> bool { + let Some(path) = pref_path(app) else { + return true; + }; + match std::fs::read_to_string(&path) { + Ok(s) => match s.trim().to_ascii_lowercase().as_str() { + "false" => false, + "true" => true, + _ => true, + }, + Err(_) => true, + } +} + +#[tauri::command] +pub fn get_auto_update_enabled(app: AppHandle) -> bool { + read_auto_update_enabled(&app) +} + +#[tauri::command] +pub fn set_auto_update_enabled(app: AppHandle, enabled: bool) -> Result<(), String> { + let path = pref_path(&app).ok_or_else(|| "app_local_data_dir unavailable".to_string())?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&path, if enabled { "true" } else { "false" }).map_err(|e| e.to_string())?; + if enabled { + // Auto-update is back on — drop any "⬆ Update available" tray hint; + // the next background poll will trigger the usual auto-download. + let r = app.state::>>(); + let mut g = r.lock_or_recover(); + if g.update_available_pending.take().is_some() { + drop(g); + crate::tray::refresh_tray(&app); + } + } + Ok(()) +} diff --git a/src-tauri/src/background.rs b/src-tauri/src/background.rs index 924aba13..ed7d61ee 100644 --- a/src-tauri/src/background.rs +++ b/src-tauri/src/background.rs @@ -226,6 +226,17 @@ fn spawn_updater_poll(handle: &AppHandle) { Err(_) => eprintln!("[updater] check timed out after 30 s"), Ok(Ok(Some(update))) => { eprintln!("[updater] update available: {}", update.version); + // When auto-update is off, mirror the version into AppState so + // the tray menu can surface "⬆ Update available …". The + // frontend gets the same event either way and decides whether + // to auto-download. + if !crate::auto_update::read_auto_update_enabled(&app) { + let r = app.state::>>(); + let mut g = r.lock_or_recover(); + g.update_available_pending = Some(update.version.clone()); + drop(g); + crate::tray::refresh_tray(&app); + } let payload = serde_json::json!({ "version": update.version, "date": update.date, diff --git a/src-tauri/src/daemon_cmds.rs b/src-tauri/src/daemon_cmds.rs index f69e7b07..18a44886 100644 --- a/src-tauri/src/daemon_cmds.rs +++ b/src-tauri/src/daemon_cmds.rs @@ -183,111 +183,13 @@ fn resolve_daemon_bin_path() -> String { } } -fn daemon_rollback_bin_path() -> Result { - let base = - dirs::config_dir().ok_or_else(|| "unable to resolve config directory".to_string())?; - let mut p = base.join("skill").join("daemon").join("bin"); - let name = if cfg!(target_os = "windows") { - "skill-daemon.rollback.exe" - } else { - "skill-daemon.rollback" - }; - p.push(name); - Ok(p) -} - -/// Path to the file that records the app version of the last daemon launch. -fn last_app_version_path() -> PathBuf { - let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("/tmp")); - p.push("skill"); - p.push("daemon"); - p.push("last_app_version"); - p -} - /// The current app version baked in at compile time. const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Returns `true` when the stored version differs from the current app version, -/// meaning the daemon binary has likely been replaced by a fresh install. -fn app_version_changed() -> bool { - let marker = last_app_version_path(); - let previous = std::fs::read_to_string(&marker).unwrap_or_default(); - previous.trim() != APP_VERSION -} - -/// Check whether the running daemon was started by a different app version. -/// If so, kill the old daemon and unload its service so the caller can spawn -/// the new one bundled with this app. Returns `true` if a restart was forced. -fn upgrade_daemon_if_app_version_changed() -> bool { - if !app_version_changed() { - return false; - } - - let marker = last_app_version_path(); - let previous = std::fs::read_to_string(&marker).unwrap_or_default(); - eprintln!( - "[daemon] app version changed ({} → {}) — replacing daemon", - if previous.trim().is_empty() { - "" - } else { - previous.trim() - }, - APP_VERSION - ); - restart_daemon_process_best_effort(); - // Give the OS a moment to release the port. - std::thread::sleep(Duration::from_millis(500)); - true -} - -/// Record the current app version so future launches can detect upgrades. -fn stamp_app_version() { - let marker = last_app_version_path(); - if let Some(parent) = marker.parent() { - let _ = std::fs::create_dir_all(parent); - } - let _ = std::fs::write(&marker, APP_VERSION); -} - -fn update_daemon_rollback_snapshot_best_effort() { - let src = std::env::var("SKILL_DAEMON_BIN").unwrap_or_else(|_| resolve_daemon_bin_path()); - let src_path = PathBuf::from(&src); - if !src_path.exists() { - return; - } - - let Ok(dst_path) = daemon_rollback_bin_path() else { - return; - }; - - if src_path == dst_path { - return; - } - - if let (Ok(src_meta), Ok(dst_meta)) = - (std::fs::metadata(&src_path), std::fs::metadata(&dst_path)) - { - // Skip copy when the source binary hasn't changed (same size AND same mtime). - let same_size = src_meta.len() == dst_meta.len(); - let same_mtime = - src_meta.modified().ok() == dst_meta.modified().ok() && src_meta.modified().is_ok(); - if same_size && same_mtime { - return; - } - } - - if let Some(parent) = dst_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if std::fs::copy(&src_path, &dst_path).is_ok() { - #[cfg(not(target_os = "windows"))] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&dst_path, std::fs::Permissions::from_mode(0o755)); - } - eprintln!("[daemon] updated rollback snapshot: {}", dst_path.display()); - } +/// Path to the rollback (last-known-good) daemon binary. Delegates to the +/// upgrade module so paths stay in one place. +fn daemon_rollback_bin_path() -> Result { + Ok(crate::daemon_upgrade::rollback_bin_path()) } /// Ask the running daemon to install itself as a persistent OS service @@ -378,7 +280,7 @@ fn ensure_daemon_running_blocking() { "[daemon] port {} is occupied but daemon is unresponsive — killing occupant", addr.port() ); - kill_port_occupant(addr.port()); + crate::daemon_upgrade::kill_port_owner(addr.port()); // Give the OS a moment to release the port. std::thread::sleep(Duration::from_millis(500)); } @@ -1299,231 +1201,216 @@ fn wait_for_protocol_compatibility(timeout: Duration) -> Result { Err(last_err.unwrap_or_else(|| "timed out waiting for daemon version".to_string())) } -/// Kill whatever process is listening on `port` (best-effort). -/// Uses `lsof` on macOS/Linux and `netstat` on Windows to find the PID. -fn kill_port_occupant(port: u16) { - #[cfg(any(target_os = "macos", target_os = "linux"))] - { - if let Ok(output) = std::process::Command::new("lsof") - .args(["-t", "-i", &format!("tcp:{port}")]) - .output() - { - let pids = String::from_utf8_lossy(&output.stdout); - for pid in pids.split_whitespace() { - eprintln!("[daemon] killing PID {pid} occupying port {port}"); - let _ = std::process::Command::new("kill") - .args(["-9", pid]) - .output(); - } - } +/// Kill the running daemon by pidfile, escalating SIGTERM → SIGKILL, then +/// fall back to killing whoever is bound to the daemon port. Used by +/// [`force_restart_daemon`]; the upgrade state machine drives the same +/// helpers directly. +fn restart_daemon_process_best_effort() { + crate::daemon_upgrade::unload_os_service_best_effort(); + let _ = crate::daemon_upgrade::kill_pidfile_daemon(); + let port = daemon_port(); + if !crate::daemon_upgrade::wait_for_port_free(port, Duration::from_secs(2)) { + crate::daemon_upgrade::kill_port_owner(port); } +} - #[cfg(target_os = "windows")] - { - if let Ok(output) = std::process::Command::new("netstat") - .args(["-ano", "-p", "TCP"]) - .output() - { - let text = String::from_utf8_lossy(&output.stdout); - let needle = format!(":{port}"); - for line in text.lines() { - if line.contains(&needle) && line.contains("LISTENING") { - if let Some(pid) = line.split_whitespace().last() { - eprintln!("[daemon] killing PID {pid} occupying port {port}"); - let _ = std::process::Command::new("taskkill") - .args(["/PID", pid, "/F"]) - .output(); - } - } - } - } - } +fn daemon_port() -> u16 { + std::env::var("SKILL_DAEMON_ADDR") + .ok() + .and_then(|a| a.parse::().ok()) + .map(|a| a.port()) + .unwrap_or(18444) } -fn restart_daemon_process_best_effort() { - // Unload the OS-level keep-alive service first, otherwise launchd / - // systemd will immediately respawn the daemon after we kill it. - unload_daemon_service_best_effort(); +fn daemon_socket_addr() -> std::net::SocketAddr { + std::env::var("SKILL_DAEMON_ADDR") + .ok() + .and_then(|a| a.parse().ok()) + .unwrap_or_else(|| std::net::SocketAddr::from(([127, 0, 0, 1], 18444))) +} +/// Spawn the daemon binary at `bin` and wait until it answers a `/version` +/// request or `timeout` elapses. Returns `Ok(true)` on protocol compatibility. +fn spawn_and_health_check(bin: &std::path::Path, timeout: Duration) -> Result { + let mut cmd = std::process::Command::new(bin); + cmd.env( + "SKILL_DAEMON_ADDR", + std::env::var("SKILL_DAEMON_ADDR").unwrap_or_else(|_| "127.0.0.1:18444".to_string()), + ) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()); #[cfg(target_os = "windows")] { - let _ = std::process::Command::new("taskkill") - .args(["/IM", "skill-daemon.exe", "/F"]) - .output(); + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); } + cmd.spawn().map_err(|e| format!("spawn failed: {e}"))?; - #[cfg(any(target_os = "macos", target_os = "linux"))] - { - let _ = std::process::Command::new("pkill") - .args(["-f", "skill-daemon"]) - .output(); + let addr = daemon_socket_addr(); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() { + // Port is bound — now verify protocol. + return wait_for_protocol_compatibility(Duration::from_secs(5)); + } + std::thread::sleep(Duration::from_millis(150)); } - - // Belt-and-suspenders: also kill by port in case the process name - // doesn't match (e.g. renamed binary, wrapper script). - let port: u16 = std::env::var("SKILL_DAEMON_ADDR") - .ok() - .and_then(|a| a.parse::().ok()) - .map(|a| a.port()) - .unwrap_or(18444); - kill_port_occupant(port); + Err("health check timed out".into()) } -/// Unload the daemon's OS-level keep-alive service so that killing the -/// process doesn't cause an immediate respawn. User-space only — no root. -fn unload_daemon_service_best_effort() { - #[cfg(target_os = "macos")] - { - let plist = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("Library/LaunchAgents/com.skill.daemon.plist"); - if plist.exists() { - eprintln!("[daemon] unloading LaunchAgent before kill"); - let _ = std::process::Command::new("launchctl") - .args(["unload", "-w"]) - .arg(&plist) - .output(); +/// One full stop→start→verify pass for a candidate daemon binary. +fn try_install_and_start(bin: &std::path::Path) -> Result<(), String> { + use crate::daemon_upgrade::*; + log_event("stop", "begin", Some(&bin.display().to_string())); + unload_os_service_best_effort(); + let _ = kill_pidfile_daemon(); + let port = daemon_port(); + if !wait_for_port_free(port, Duration::from_secs(5)) { + log_event("stop", "port_still_bound", Some(&port.to_string())); + kill_port_owner(port); + if !wait_for_port_free(port, Duration::from_secs(2)) { + return Err(format!("port {port} still bound after kill")); } } - - #[cfg(target_os = "linux")] - { - let _ = std::process::Command::new("systemctl") - .args(["--user", "stop", "skill-daemon.service"]) - .output(); + log_event("start", "spawn", Some(&bin.display().to_string())); + match spawn_and_health_check(bin, Duration::from_secs(10))? { + true => { + log_event("verify", "ok", None); + Ok(()) + } + false => Err("protocol mismatch".into()), } } pub(crate) fn ensure_daemon_runtime_ready() { - // On fresh install / upgrade, kill the old daemon so we launch the new one. - upgrade_daemon_if_app_version_changed(); - - // Block until the daemon is reachable (or spawn fails). - ensure_daemon_running_blocking(); - - let mut compatible = false; - - // First attempt: check protocol compatibility. - match wait_for_protocol_compatibility(Duration::from_secs(5)) { - Ok(true) => { - compatible = true; + use crate::daemon_upgrade::*; + let bundled = std::env::var("SKILL_DAEMON_BIN").unwrap_or_else(|_| resolve_daemon_bin_path()); + let bundled_path = PathBuf::from(&bundled); + let bundled_hash = sha256_file(&bundled_path); + + let mut state = load_state(); + + // Fast path: hash matches, last phase was Ready, daemon already healthy. + if let Some(h) = &bundled_hash { + if state.phase == Phase::Ready + && state.installed_hash.as_deref() == Some(h.as_str()) + && wait_for_protocol_compatibility(Duration::from_millis(800)) == Ok(true) + { + log_event("ready", "no_change", Some(h)); + ensure_daemon_background_service(); + return; } - not_ok => { - match ¬_ok { - Ok(false) => eprintln!("[daemon] protocol mismatch — attempting restart"), - Err(e) => eprintln!("[daemon] protocol check failed: {e} — attempting restart"), - _ => unreachable!(), + } + + log_event( + "upgrade", + "begin", + Some(&format!( + "version={APP_VERSION} bundled_hash={} installed_hash={}", + bundled_hash.as_deref().unwrap_or("?"), + state.installed_hash.as_deref().unwrap_or("?") + )), + ); + state.phase = Phase::Upgrading; + state.attempt_count = 0; + state.last_error = None; + save_state(&mut state); + + let mut succeeded = false; + while state.attempt_count < MAX_ATTEMPTS_PUB { + state.attempt_count += 1; + save_state(&mut state); + match try_install_and_start(&bundled_path) { + Ok(()) => { + succeeded = true; + break; } - // Kill and respawn the daemon, then re-check. - restart_daemon_process_best_effort(); - std::thread::sleep(Duration::from_millis(300)); - ensure_daemon_running_blocking(); - if let Ok(true) = wait_for_protocol_compatibility(Duration::from_secs(5)) { - compatible = true; - eprintln!("[daemon] protocol compatibility restored after restart"); + Err(e) => { + log_event("upgrade", "attempt_failed", Some(&e)); + state.last_error = Some(e); + save_state(&mut state); } } } - // Rollback: try the last-known-good daemon binary. - if !compatible { - if let Ok(rollback_bin) = daemon_rollback_bin_path() { - if rollback_bin.exists() { - eprintln!( - "[daemon] attempting rollback daemon: {}", - rollback_bin.display() - ); - restart_daemon_process_best_effort(); - std::thread::sleep(Duration::from_millis(300)); - - let prev = std::env::var("SKILL_DAEMON_BIN").ok(); - std::env::set_var("SKILL_DAEMON_BIN", rollback_bin.display().to_string()); - ensure_daemon_running_blocking(); - if let Some(v) = prev { - std::env::set_var("SKILL_DAEMON_BIN", v); + if succeeded { + // Refresh rollback only if hash differs (atomic copy). + if let Some(h) = &bundled_hash { + if state.rollback_hash.as_deref() != Some(h.as_str()) { + let dst = rollback_bin_path(); + if let Err(e) = copy_atomic(&bundled_path, &dst) { + log_event("rollback_snapshot", "copy_failed", Some(&e.to_string())); } else { - std::env::remove_var("SKILL_DAEMON_BIN"); - } - - match wait_for_protocol_compatibility(Duration::from_secs(5)) { - Ok(true) => { - compatible = true; - eprintln!("[daemon] rollback daemon restored compatibility"); - } - Ok(false) => eprintln!("[daemon] rollback daemon still incompatible — continuing degraded"), - Err(e) => eprintln!("[daemon] rollback daemon failed readiness check: {e} — continuing degraded"), + state.rollback_hash = bundled_hash.clone(); + state.rollback_version = Some(APP_VERSION.to_string()); + log_event( + "rollback_snapshot", + "updated", + Some(&dst.display().to_string()), + ); } - } else { - eprintln!( - "[daemon] no rollback daemon snapshot found at {}", - rollback_bin.display() - ); } } + state.installed_hash = bundled_hash; + state.installed_version = Some(APP_VERSION.to_string()); + state.phase = Phase::Ready; + state.attempt_count = 0; + state.last_error = None; + save_state(&mut state); + log_event("upgrade", "succeeded", state.installed_hash.as_deref()); + // Force fresh OS-service registration so the LaunchAgent / systemd unit / + // Windows service points at the just-installed binary path. Idempotent. + force_reinstall_os_service(); + return; } - if compatible { - update_daemon_rollback_snapshot_best_effort(); - stamp_app_version(); + // ── Rollback ───────────────────────────────────────────────────────────── + state.phase = Phase::RollingBack; + save_state(&mut state); + let rollback = rollback_bin_path(); + if rollback.exists() { + log_event("rollback", "attempt", Some(&rollback.display().to_string())); + match try_install_and_start(&rollback) { + Ok(()) => { + state.installed_hash = state.rollback_hash.clone(); + state.installed_version = state.rollback_version.clone(); + state.phase = Phase::Ready; + state.attempt_count = 0; + state.last_error = None; + save_state(&mut state); + log_event("rollback", "succeeded", None); + force_reinstall_os_service(); + return; + } + Err(e) => { + log_event("rollback", "failed", Some(&e)); + state.last_error = Some(e); + } + } + } else { + log_event("rollback", "no_snapshot", None); } + state.phase = Phase::Failed; + save_state(&mut state); + log_event("upgrade", "failed_terminal", state.last_error.as_deref()); + // Still try to register service so a manual restart by the user picks up. ensure_daemon_background_service(); } -#[cfg(test)] -fn ensure_daemon_runtime_ready_with_hooks< - FEnsure, - FWait, - FRestart, - FRollbackExists, - FRollbackLaunch, - FSnapshot, - FService, ->( - mut ensure_running: FEnsure, - mut wait: FWait, - mut restart: FRestart, - rollback_exists: FRollbackExists, - mut launch_rollback: FRollbackLaunch, - mut snapshot: FSnapshot, - mut service: FService, -) -> bool -where - FEnsure: FnMut(), - FWait: FnMut() -> Result, - FRestart: FnMut(), - FRollbackExists: Fn() -> bool, - FRollbackLaunch: FnMut(), - FSnapshot: FnMut(), - FService: FnMut(), -{ - ensure_running(); - - let mut compatible = false; - match wait() { - Ok(true) => compatible = true, - Ok(false) | Err(_) => { - restart(); - ensure_running(); - if let Ok(true) = wait() { - compatible = true; - } - } - } +/// Maximum number of stop→start attempts before falling back to rollback. +const MAX_ATTEMPTS_PUB: u32 = 2; - if !compatible && rollback_exists() { - restart(); - launch_rollback(); - if let Ok(true) = wait() { - compatible = true; - } +/// Force the daemon to re-register its OS service. Idempotent; on success +/// the LaunchAgent / systemd unit / Windows service points at the daemon +/// binary that just answered our health check. +fn force_reinstall_os_service() { + use crate::daemon_upgrade::log_event; + match install_daemon_service() { + Ok(_) => log_event("service", "reinstalled", None), + Err(e) => log_event("service", "reinstall_failed", Some(&e)), } - - if compatible { - snapshot(); - } - service(); - compatible } pub(crate) fn ensure_daemon_background_service() { @@ -1585,6 +1472,12 @@ fn load_daemon_token() -> Result { } fn token_path() -> Result { + // Honor the same SKILL_DAEMON_CONFIG_ROOT escape hatch as + // daemon_upgrade::config_root, so e2e tests can pin every daemon-related + // path under one tmpdir without touching $HOME / $XDG_CONFIG_HOME. + if let Ok(root) = std::env::var("SKILL_DAEMON_CONFIG_ROOT") { + return Ok(PathBuf::from(root).join("auth.token")); + } let base = dirs::config_dir().ok_or_else(|| "unable to resolve config directory".to_string())?; Ok(base.join("skill").join("daemon").join("auth.token")) @@ -2504,142 +2397,6 @@ mod tests { server.join().unwrap(); } - #[test] - fn runtime_ready_attempts_rollback_after_restart_mismatch() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let mut waits = std::collections::VecDeque::from([ - Ok(false), // initial check: mismatch - Ok(false), // after restart: still mismatch - Ok(true), // after rollback launch: compatible - ]); - - let mut ensure_count = 0usize; - let mut restart_count = 0usize; - let mut rollback_launch_count = 0usize; - let mut snapshot_count = 0usize; - let mut service_count = 0usize; - - let compatible = ensure_daemon_runtime_ready_with_hooks( - || ensure_count += 1, - || waits.pop_front().unwrap_or(Err("no wait result".into())), - || restart_count += 1, - || true, - || rollback_launch_count += 1, - || snapshot_count += 1, - || service_count += 1, - ); - - assert!(compatible); - assert_eq!(ensure_count, 2, "initial ensure + post-restart ensure"); - assert_eq!(restart_count, 2, "restart path + rollback path"); - assert_eq!( - rollback_launch_count, 1, - "rollback should be launched exactly once" - ); - assert_eq!( - snapshot_count, 1, - "compatible runtime should refresh rollback snapshot" - ); - assert_eq!( - service_count, 1, - "background service repair should always run" - ); - } - - #[test] - fn runtime_ready_degraded_when_wait_errors_and_no_rollback() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let mut waits = std::collections::VecDeque::from([ - Err("unreachable".to_string()), - Err("still unreachable".to_string()), - ]); - - let mut ensure_count = 0usize; - let mut restart_count = 0usize; - let mut rollback_launch_count = 0usize; - let mut snapshot_count = 0usize; - let mut service_count = 0usize; - - let compatible = ensure_daemon_runtime_ready_with_hooks( - || ensure_count += 1, - || waits.pop_front().unwrap_or(Err("no wait result".into())), - || restart_count += 1, - || false, - || rollback_launch_count += 1, - || snapshot_count += 1, - || service_count += 1, - ); - - assert!(!compatible); - assert_eq!(ensure_count, 2, "initial ensure + post-restart ensure"); - assert_eq!(restart_count, 1, "error triggers a restart attempt"); - assert_eq!(rollback_launch_count, 0); - assert_eq!(snapshot_count, 0, "no snapshot refresh on degraded startup"); - assert_eq!(service_count, 1); - } - - #[test] - fn runtime_ready_happy_path_no_restart_or_rollback() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let mut waits = std::collections::VecDeque::from([Ok(true)]); - - let mut ensure_count = 0usize; - let mut restart_count = 0usize; - let mut rollback_launch_count = 0usize; - let mut snapshot_count = 0usize; - let mut service_count = 0usize; - - let compatible = ensure_daemon_runtime_ready_with_hooks( - || ensure_count += 1, - || waits.pop_front().unwrap_or(Err("no wait result".into())), - || restart_count += 1, - || true, - || rollback_launch_count += 1, - || snapshot_count += 1, - || service_count += 1, - ); - - assert!(compatible); - assert_eq!(ensure_count, 1); - assert_eq!(restart_count, 0); - assert_eq!(rollback_launch_count, 0); - assert_eq!(snapshot_count, 1); - assert_eq!(service_count, 1); - } - - #[test] - fn runtime_ready_recovers_after_single_restart_without_rollback() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let mut waits = std::collections::VecDeque::from([Ok(false), Ok(true)]); - - let mut ensure_count = 0usize; - let mut restart_count = 0usize; - let mut rollback_launch_count = 0usize; - let mut snapshot_count = 0usize; - let mut service_count = 0usize; - - let compatible = ensure_daemon_runtime_ready_with_hooks( - || ensure_count += 1, - || waits.pop_front().unwrap_or(Err("no wait result".into())), - || restart_count += 1, - || true, - || rollback_launch_count += 1, - || snapshot_count += 1, - || service_count += 1, - ); - - assert!(compatible); - assert_eq!(ensure_count, 2); - assert_eq!(restart_count, 1); - assert_eq!(rollback_launch_count, 0); - assert_eq!(snapshot_count, 1); - assert_eq!(service_count, 1); - } - #[test] fn service_autoinstall_disabled_by_env_is_noop() { let _guard = daemon_cmds_test_lock().lock().unwrap(); @@ -2651,173 +2408,6 @@ mod tests { std::env::remove_var("SKILL_DAEMON_SERVICE_AUTOINSTALL"); } - #[test] - fn rollback_snapshot_copies_current_daemon_bin() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let td = tempfile::tempdir().unwrap(); - std::env::set_var("HOME", td.path()); - std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); - std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - - let fake_bin = td.path().join(if cfg!(target_os = "windows") { - "skill-daemon.exe" - } else { - "skill-daemon" - }); - std::fs::write(&fake_bin, b"daemon-binary").unwrap(); - std::env::set_var("SKILL_DAEMON_BIN", &fake_bin); - - update_daemon_rollback_snapshot_best_effort(); - - let rollback = daemon_rollback_bin_path().unwrap(); - assert!(rollback.exists(), "rollback snapshot should exist"); - - let src = std::fs::read(&fake_bin).unwrap(); - let dst = std::fs::read(&rollback).unwrap(); - assert_eq!(src, dst); - - std::env::remove_var("SKILL_DAEMON_BIN"); - } - - #[test] - fn runtime_ready_fails_when_rollback_also_incompatible() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let mut waits = std::collections::VecDeque::from([ - Ok(false), // initial mismatch - Ok(false), // after restart mismatch - Ok(false), // rollback also mismatch - ]); - - let mut ensure_count = 0usize; - let mut restart_count = 0usize; - let mut rollback_launch_count = 0usize; - let mut snapshot_count = 0usize; - let mut service_count = 0usize; - - let compatible = ensure_daemon_runtime_ready_with_hooks( - || ensure_count += 1, - || waits.pop_front().unwrap_or(Err("no wait result".into())), - || restart_count += 1, - || true, - || rollback_launch_count += 1, - || snapshot_count += 1, - || service_count += 1, - ); - - assert!(!compatible); - assert_eq!(ensure_count, 2); - assert_eq!(restart_count, 2); - assert_eq!(rollback_launch_count, 1); - assert_eq!( - snapshot_count, 0, - "no snapshot update on incompatible runtime" - ); - assert_eq!(service_count, 1); - } - - #[test] - fn rollback_snapshot_noop_when_source_missing() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let td = tempfile::tempdir().unwrap(); - std::env::set_var("HOME", td.path()); - std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); - std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - - let fake_missing = td.path().join(if cfg!(target_os = "windows") { - "missing-daemon.exe" - } else { - "missing-daemon" - }); - std::env::set_var("SKILL_DAEMON_BIN", &fake_missing); - - let rollback = daemon_rollback_bin_path().unwrap(); - if rollback.exists() { - let _ = std::fs::remove_file(&rollback); - } - - update_daemon_rollback_snapshot_best_effort(); - - assert!( - !rollback.exists(), - "rollback snapshot should not be created when source binary is missing" - ); - - std::env::remove_var("SKILL_DAEMON_BIN"); - } - - #[test] - fn runtime_ready_recovers_after_initial_probe_error() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let mut waits = std::collections::VecDeque::from([ - Err("initial probe failed".to_string()), - Ok(true), // post-restart probe succeeds - ]); - - let mut ensure_count = 0usize; - let mut restart_count = 0usize; - let mut rollback_launch_count = 0usize; - let mut snapshot_count = 0usize; - let mut service_count = 0usize; - - let compatible = ensure_daemon_runtime_ready_with_hooks( - || ensure_count += 1, - || waits.pop_front().unwrap_or(Err("no wait result".into())), - || restart_count += 1, - || true, - || rollback_launch_count += 1, - || snapshot_count += 1, - || service_count += 1, - ); - - assert!(compatible); - assert_eq!(ensure_count, 2, "initial ensure + post-restart ensure"); - assert_eq!(restart_count, 1, "error triggers a restart attempt"); - assert_eq!( - rollback_launch_count, 0, - "no rollback needed — restart fixed it" - ); - assert_eq!(snapshot_count, 1); - assert_eq!(service_count, 1); - } - - #[test] - fn runtime_ready_uses_rollback_when_restart_fails_after_probe_error() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let mut waits = std::collections::VecDeque::from([ - Err("initial probe failed".to_string()), - Err("restart probe also failed".to_string()), - Ok(true), // rollback probe succeeds - ]); - - let mut ensure_count = 0usize; - let mut restart_count = 0usize; - let mut rollback_launch_count = 0usize; - let mut snapshot_count = 0usize; - let mut service_count = 0usize; - - let compatible = ensure_daemon_runtime_ready_with_hooks( - || ensure_count += 1, - || waits.pop_front().unwrap_or(Err("no wait result".into())), - || restart_count += 1, - || true, - || rollback_launch_count += 1, - || snapshot_count += 1, - || service_count += 1, - ); - - assert!(compatible); - assert_eq!(ensure_count, 2, "initial ensure + post-restart ensure"); - assert_eq!(restart_count, 2, "error-path restart + rollback restart"); - assert_eq!(rollback_launch_count, 1); - assert_eq!(snapshot_count, 1); - assert_eq!(service_count, 1); - } - #[test] fn service_autoinstall_unknown_status_does_not_install() { let _guard = daemon_cmds_test_lock().lock().unwrap(); @@ -2861,39 +2451,6 @@ mod tests { std::env::remove_var("SKILL_DAEMON_REQUIRED"); } - #[test] - fn runtime_ready_degraded_after_restart_error_without_rollback() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let mut waits = std::collections::VecDeque::from([ - Ok(false), // initial mismatch - Err("restart probe failed".to_string()), - ]); - - let mut ensure_count = 0usize; - let mut restart_count = 0usize; - let mut rollback_launch_count = 0usize; - let mut snapshot_count = 0usize; - let mut service_count = 0usize; - - let compatible = ensure_daemon_runtime_ready_with_hooks( - || ensure_count += 1, - || waits.pop_front().unwrap_or(Err("no wait result".into())), - || restart_count += 1, - || false, - || rollback_launch_count += 1, - || snapshot_count += 1, - || service_count += 1, - ); - - assert!(!compatible); - assert_eq!(ensure_count, 2); - assert_eq!(restart_count, 1); - assert_eq!(rollback_launch_count, 0); - assert_eq!(snapshot_count, 0); - assert_eq!(service_count, 1); - } - #[test] fn wait_for_protocol_compatibility_times_out_without_token() { let _guard = daemon_cmds_test_lock().lock().unwrap(); @@ -3143,60 +2700,6 @@ mod tests { server.join().unwrap(); } - #[test] - fn rollback_snapshot_overwrites_when_source_changes() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let td = tempfile::tempdir().unwrap(); - std::env::set_var("HOME", td.path()); - std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); - std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - - let fake_bin = td.path().join(if cfg!(target_os = "windows") { - "skill-daemon.exe" - } else { - "skill-daemon" - }); - std::fs::write(&fake_bin, b"daemon-v1").unwrap(); - std::env::set_var("SKILL_DAEMON_BIN", &fake_bin); - - // First copy - update_daemon_rollback_snapshot_best_effort(); - let rollback = daemon_rollback_bin_path().unwrap(); - assert_eq!(std::fs::read(&rollback).unwrap(), b"daemon-v1"); - - // Update source binary (different size triggers re-copy) - std::fs::write(&fake_bin, b"daemon-v2-longer").unwrap(); - update_daemon_rollback_snapshot_best_effort(); - assert_eq!(std::fs::read(&rollback).unwrap(), b"daemon-v2-longer"); - - std::env::remove_var("SKILL_DAEMON_BIN"); - } - - #[test] - fn rollback_snapshot_skips_when_src_equals_dst() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let td = tempfile::tempdir().unwrap(); - std::env::set_var("HOME", td.path()); - std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); - std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - - // Point SKILL_DAEMON_BIN to the rollback path itself - let rollback = daemon_rollback_bin_path().unwrap(); - if let Some(parent) = rollback.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(&rollback, b"self-binary").unwrap(); - std::env::set_var("SKILL_DAEMON_BIN", &rollback); - - // Should be a no-op (src == dst) - update_daemon_rollback_snapshot_best_effort(); - assert_eq!(std::fs::read(&rollback).unwrap(), b"self-binary"); - - std::env::remove_var("SKILL_DAEMON_BIN"); - } - #[test] fn http_contract_reconnect_and_catalog_routes() { let _guard = daemon_cmds_test_lock().lock().unwrap(); @@ -4464,60 +3967,10 @@ mod async_contract_tests { server.join().unwrap(); } - // ── App-version upgrade detection tests ──────────────────────────── - - #[test] - fn stamp_app_version_creates_marker_file() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let td = tempfile::tempdir().unwrap(); - std::env::set_var("HOME", td.path()); - std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); - std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - - let marker = last_app_version_path(); - assert!(!marker.exists(), "marker should not exist before stamp"); - - stamp_app_version(); - - assert!(marker.exists(), "marker should exist after stamp"); - let contents = std::fs::read_to_string(&marker).unwrap(); - assert_eq!(contents, APP_VERSION); - } - - #[test] - fn app_version_changed_returns_true_when_no_marker() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let td = tempfile::tempdir().unwrap(); - std::env::set_var("HOME", td.path()); - std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); - std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - - assert!( - app_version_changed(), - "should detect change when marker file is absent" - ); - } - - #[test] - fn app_version_changed_returns_false_after_stamp() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let td = tempfile::tempdir().unwrap(); - std::env::set_var("HOME", td.path()); - std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); - std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - - stamp_app_version(); - assert!( - !app_version_changed(), - "should not detect change right after stamping" - ); - } + // ── Upgrade state machine smoke tests ─────────────────────────────── #[test] - fn app_version_changed_returns_true_when_marker_differs() { + fn upgrade_state_round_trip() { let _guard = daemon_cmds_test_lock().lock().unwrap(); let td = tempfile::tempdir().unwrap(); @@ -4525,56 +3978,26 @@ mod async_contract_tests { std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - let marker = last_app_version_path(); - std::fs::create_dir_all(marker.parent().unwrap()).unwrap(); - std::fs::write(&marker, "0.0.1-old").unwrap(); + let mut s = crate::daemon_upgrade::load_state(); + assert!(matches!(s.phase, crate::daemon_upgrade::Phase::Ready)); + s.installed_hash = Some("abc".into()); + s.phase = crate::daemon_upgrade::Phase::Upgrading; + crate::daemon_upgrade::save_state(&mut s); - assert!( - app_version_changed(), - "should detect change when marker contains a different version" - ); + let s2 = crate::daemon_upgrade::load_state(); + assert_eq!(s2.installed_hash.as_deref(), Some("abc")); + assert!(matches!(s2.phase, crate::daemon_upgrade::Phase::Upgrading)); } #[test] - fn app_version_changed_ignores_trailing_whitespace() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - + fn upgrade_sha256_file_matches_known_value() { let td = tempfile::tempdir().unwrap(); - std::env::set_var("HOME", td.path()); - std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); - std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - - let marker = last_app_version_path(); - std::fs::create_dir_all(marker.parent().unwrap()).unwrap(); - std::fs::write(&marker, format!("{}\n ", APP_VERSION)).unwrap(); - - assert!( - !app_version_changed(), - "should treat version with trailing whitespace as matching" - ); - } - - #[test] - fn stamp_overwrites_previous_version() { - let _guard = daemon_cmds_test_lock().lock().unwrap(); - - let td = tempfile::tempdir().unwrap(); - std::env::set_var("HOME", td.path()); - std::env::set_var("XDG_CONFIG_HOME", td.path().join(".config")); - std::env::set_var("APPDATA", td.path().join("AppData/Roaming")); - - let marker = last_app_version_path(); - std::fs::create_dir_all(marker.parent().unwrap()).unwrap(); - std::fs::write(&marker, "0.0.1-old").unwrap(); - - assert!(app_version_changed()); - - stamp_app_version(); - - assert!( - !app_version_changed(), - "stamp should overwrite old version so change is no longer detected" + let p = td.path().join("hello.bin"); + std::fs::write(&p, b"hello").unwrap(); + // sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + assert_eq!( + crate::daemon_upgrade::sha256_file(&p).as_deref(), + Some("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824") ); - assert_eq!(std::fs::read_to_string(&marker).unwrap(), APP_VERSION); } } diff --git a/src-tauri/src/daemon_upgrade.rs b/src-tauri/src/daemon_upgrade.rs new file mode 100644 index 00000000..bb3f98f9 --- /dev/null +++ b/src-tauri/src/daemon_upgrade.rs @@ -0,0 +1,1020 @@ +// SPDX-License-Identifier: GPL-3.0-only +// +// Failsafe daemon upgrade protocol. +// +// Goals: idempotent, atomic, observable. On every app launch we reconcile +// the running daemon against the binary bundled with this app. State lives +// in `~/.config/skill/daemon/state.json`; per-phase events are appended to +// `~/.config/skill/daemon/upgrade.log`. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +const STATE_VERSION: u32 = 1; +const KILL_GRACE: Duration = Duration::from_secs(3); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Ready, + Upgrading, + RollingBack, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpgradeState { + pub version: u32, + pub installed_hash: Option, + pub installed_version: Option, + pub rollback_hash: Option, + pub rollback_version: Option, + pub phase: Phase, + pub attempt_count: u32, + pub last_error: Option, + pub updated_at: String, +} + +impl Default for UpgradeState { + fn default() -> Self { + Self { + version: STATE_VERSION, + installed_hash: None, + installed_version: None, + rollback_hash: None, + rollback_version: None, + phase: Phase::Ready, + attempt_count: 0, + last_error: None, + updated_at: now_iso(), + } + } +} + +// ─── Paths ─────────────────────────────────────────────────────────────────── + +fn config_root() -> PathBuf { + // Test/sandbox escape hatch — keeps the upgrade state and pidfile path + // overridable without touching HOME/XDG_CONFIG_HOME (which would affect + // unrelated libs). The daemon binary itself reads the same variable. + if let Ok(p) = std::env::var("SKILL_DAEMON_CONFIG_ROOT") { + return PathBuf::from(p); + } + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("skill") + .join("daemon") +} + +pub fn state_path() -> PathBuf { + config_root().join("state.json") +} + +pub fn upgrade_log_path() -> PathBuf { + config_root().join("upgrade.log") +} + +pub fn pidfile_path() -> PathBuf { + config_root().join("daemon.pid") +} + +pub fn rollback_bin_path() -> PathBuf { + let name = if cfg!(target_os = "windows") { + "skill-daemon.rollback.exe" + } else { + "skill-daemon.rollback" + }; + config_root().join("bin").join(name) +} + +// ─── State load/save ───────────────────────────────────────────────────────── + +pub fn load_state() -> UpgradeState { + let path = state_path(); + let Ok(bytes) = fs::read(&path) else { + return UpgradeState::default(); + }; + serde_json::from_slice(&bytes).unwrap_or_default() +} + +pub fn save_state(state: &mut UpgradeState) { + state.version = STATE_VERSION; + state.updated_at = now_iso(); + let path = state_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let tmp = path.with_extension("json.tmp"); + if let Ok(bytes) = serde_json::to_vec_pretty(state) { + if fs::write(&tmp, &bytes).is_ok() { + let _ = fs::rename(&tmp, &path); + } + } +} + +// ─── Logging ───────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct LogEntry<'a> { + ts: String, + phase: &'a str, + event: &'a str, + detail: Option<&'a str>, +} + +pub fn log_event(phase: &str, event: &str, detail: Option<&str>) { + eprintln!( + "[upgrade] {phase}/{event}{}", + detail.map(|d| format!(": {d}")).unwrap_or_default() + ); + let entry = LogEntry { + ts: now_iso(), + phase, + event, + detail, + }; + let Ok(mut line) = serde_json::to_string(&entry) else { + return; + }; + line.push('\n'); + let path = upgrade_log_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&path) { + use std::io::Write; + let _ = f.write_all(line.as_bytes()); + } +} + +// ─── Hashing ───────────────────────────────────────────────────────────────── + +pub fn sha256_file(path: &Path) -> Option { + let mut f = fs::File::open(path).ok()?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = f.read(&mut buf).ok()?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Some(format!("{:x}", hasher.finalize())) +} + +// ─── PID-based kill ────────────────────────────────────────────────────────── + +pub fn read_pidfile() -> Option { + let txt = fs::read_to_string(pidfile_path()).ok()?; + txt.trim().parse::().ok() +} + +pub fn process_alive(pid: u32) -> bool { + #[cfg(target_os = "linux")] + { + // On Linux, /proc//status reports State: Z for zombies, which + // are not "alive" for our purposes — they've been killed and are + // just waiting to be reaped by the parent. `kill(pid, 0)` would + // return 0 (alive) for them, masking successful kills in tests + // where the parent doesn't immediately waitpid. + if let Ok(s) = std::fs::read_to_string(format!("/proc/{pid}/status")) { + for line in s.lines() { + if let Some(rest) = line.strip_prefix("State:") { + let state = rest.trim().chars().next().unwrap_or(' '); + return state != 'Z' && state != 'X'; + } + } + } + // Fall through to kill(0) when /proc isn't readable. + } + #[cfg(unix)] + { + // signal 0: existence check, no actual signal sent. + // SAFETY: kill with signal 0 is always safe; it only checks process existence. + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } + } + #[cfg(target_os = "windows")] + { + let out = std::process::Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/NH"]) + .output(); + match out { + Ok(o) => String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()), + Err(_) => false, + } + } + #[cfg(not(any(unix, target_os = "windows")))] + { + let _ = pid; + false + } +} + +/// Kill the daemon by its pidfile. SIGTERM first, escalate to SIGKILL after +/// `KILL_GRACE`. Returns `true` if the process was killed (or wasn't running). +pub fn kill_pidfile_daemon() -> bool { + let Some(pid) = read_pidfile() else { + log_event("stop", "no_pidfile", None); + return true; + }; + if !process_alive(pid) { + log_event("stop", "pid_not_alive", Some(&pid.to_string())); + let _ = fs::remove_file(pidfile_path()); + return true; + } + + log_event("stop", "sigterm", Some(&pid.to_string())); + #[cfg(unix)] + // SAFETY: pid was read from our pidfile and validated; sending SIGTERM is safe. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + } + #[cfg(target_os = "windows")] + { + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string()]) + .output(); + } + + let deadline = Instant::now() + KILL_GRACE; + while Instant::now() < deadline { + if !process_alive(pid) { + let _ = fs::remove_file(pidfile_path()); + log_event("stop", "exited_after_sigterm", Some(&pid.to_string())); + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + + log_event("stop", "sigkill", Some(&pid.to_string())); + #[cfg(unix)] + // SAFETY: pid was read from our pidfile and validated; sending SIGKILL is safe. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGKILL); + } + #[cfg(target_os = "windows")] + { + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .output(); + } + std::thread::sleep(Duration::from_millis(200)); + let dead = !process_alive(pid); + if dead { + let _ = fs::remove_file(pidfile_path()); + } + dead +} + +/// Best-effort: kill whatever process is bound to `port`. +pub fn kill_port_owner(port: u16) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + if let Ok(output) = std::process::Command::new("lsof") + .args(["-t", "-i", &format!("tcp:{port}")]) + .output() + { + for pid in String::from_utf8_lossy(&output.stdout).split_whitespace() { + log_event("stop", "kill_port_owner", Some(pid)); + let _ = std::process::Command::new("kill") + .args(["-9", pid]) + .output(); + } + } + } + #[cfg(target_os = "windows")] + { + if let Ok(output) = std::process::Command::new("netstat") + .args(["-ano", "-p", "TCP"]) + .output() + { + let text = String::from_utf8_lossy(&output.stdout); + let needle = format!(":{port}"); + for line in text.lines() { + if line.contains(&needle) && line.contains("LISTENING") { + if let Some(pid) = line.split_whitespace().last() { + log_event("stop", "kill_port_owner", Some(pid)); + let _ = std::process::Command::new("taskkill") + .args(["/PID", pid, "/F"]) + .output(); + } + } + } + } + } +} + +pub fn wait_for_port_free(port: u16, timeout: Duration) -> bool { + let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into(); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + // A successful connect means someone is still listening. + if std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(150)).is_err() { + return true; + } + std::thread::sleep(Duration::from_millis(150)); + } + false +} + +// ─── OS service management (no -w, idempotent) ─────────────────────────────── + +#[cfg(target_os = "macos")] +fn launch_agent_plist() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("Library/LaunchAgents/com.skill.daemon.plist") +} + +pub fn unload_os_service_best_effort() { + #[cfg(target_os = "macos")] + { + let plist = launch_agent_plist(); + if !plist.exists() { + return; + } + // bootout cleanly stops & unloads without disabling the plist + // (which `launchctl unload -w` would do). Falls back to plain `unload` + // on macOS versions where bootout is unavailable. + // SAFETY: getuid() is always safe to call. + let uid_str = format!("gui/{}", unsafe { libc::getuid() }); + let bootout_ok = std::process::Command::new("launchctl") + .args(["bootout", &uid_str]) + .arg(&plist) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !bootout_ok { + let _ = std::process::Command::new("launchctl") + .arg("unload") + .arg(&plist) + .output(); + } + log_event("stop", "launchd_unloaded", None); + } + #[cfg(target_os = "linux")] + { + let _ = std::process::Command::new("systemctl") + .args(["--user", "stop", "skill-daemon.service"]) + .output(); + log_event("stop", "systemd_stopped", None); + } + #[cfg(target_os = "windows")] + { + let _ = std::process::Command::new("sc") + .args(["stop", "skill-daemon"]) + .output(); + log_event("stop", "sc_stopped", None); + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +fn now_iso() -> String { + chrono::Utc::now().to_rfc3339() +} + +pub fn copy_atomic(src: &Path, dst: &Path) -> std::io::Result<()> { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + let tmp = dst.with_extension("tmp"); + fs::copy(src, &tmp)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755)); + } + fs::rename(&tmp, dst)?; + Ok(()) +} + +// ─── Linux end-to-end tests against real subprocesses ──────────────────────── +// +// These tests exercise the failsafe primitives (kill, port wait, state, hash, +// atomic copy) against actual /bin/python3 stub processes, fresh tmpdirs, and +// real OS signals. They run in CI via Dockerfile.upgrade-test. +// +// Each test isolates state via SKILL_DAEMON_CONFIG_ROOT pointing at a unique +// tmpdir and grabs the env-var lock so parallel tests don't trample each +// other's $TEST_PORT / config root. +#[cfg(all(test, target_os = "linux"))] +mod linux_e2e { + use super::*; + use std::process::{Command, Stdio}; + use std::sync::Mutex; + use std::time::Instant; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + /// Embedded Python stub: writes pidfile, binds 127.0.0.1:$PORT, optionally + /// installs a SIGTERM-ignoring handler. Stays alive accepting connections + /// until killed. + const STUB: &str = r#" +import os, sys, signal, socket +pidfile = sys.argv[1] +port = int(sys.argv[2]) +ignore_term = "--ignore-sigterm" in sys.argv +s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("127.0.0.1", port)); s.listen(8) +with open(pidfile, "w") as f: f.write(str(os.getpid())) +if ignore_term: + signal.signal(signal.SIGTERM, lambda *_: None) +print("READY", flush=True) +while True: + try: + c, _ = s.accept(); c.close() + except KeyboardInterrupt: + break +"#; + + fn spawn_stub(pidfile: &Path, port: u16, ignore_term: bool) -> std::process::Child { + let mut cmd = Command::new("python3"); + cmd.arg("-u") + .arg("-c") + .arg(STUB) + .arg(pidfile) + .arg(port.to_string()); + if ignore_term { + cmd.arg("--ignore-sigterm"); + } + let mut child = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn python3 stub"); + + // Wait for "READY\n" on stdout — guarantees pidfile is written and port bound. + use std::io::{BufRead, BufReader}; + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) > 0 && line.starts_with("READY") { + return child; + } + } + child.kill().ok(); + panic!("stub did not become ready"); + } + + fn fresh_root() -> tempfile::TempDir { + tempfile::tempdir().expect("tmpdir") + } + + fn set_root(root: &Path) { + std::env::set_var("SKILL_DAEMON_CONFIG_ROOT", root); + } + + fn pick_port() -> u16 { + // Bind to 0 to grab a free port, then close — Linux kernel won't reuse + // it for a second or two, which is enough for the test to claim it. + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let p = l.local_addr().unwrap().port(); + drop(l); + p + } + + #[test] + fn kill_pidfile_terminates_responsive_process() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let td = fresh_root(); + set_root(td.path()); + let port = pick_port(); + let pf = pidfile_path(); + let mut child = spawn_stub(&pf, port, false); + + let pid_in_file = read_pidfile().expect("pidfile"); + assert_eq!(pid_in_file, child.id()); + assert!(process_alive(pid_in_file)); + + let start = Instant::now(); + assert!(kill_pidfile_daemon(), "kill should succeed"); + let elapsed = start.elapsed(); + assert!( + elapsed < Duration::from_secs(2), + "responsive process should die fast, took {elapsed:?}" + ); + assert!(!process_alive(pid_in_file)); + // Pidfile is removed on successful kill. + assert!(read_pidfile().is_none(), "pidfile should be cleaned up"); + let _ = child.wait(); + } + + #[test] + fn kill_pidfile_escalates_to_sigkill_when_sigterm_ignored() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let td = fresh_root(); + set_root(td.path()); + let port = pick_port(); + let pf = pidfile_path(); + let mut child = spawn_stub(&pf, port, true); // ignore SIGTERM + + let pid_in_file = read_pidfile().expect("pidfile"); + let start = Instant::now(); + assert!(kill_pidfile_daemon(), "SIGKILL should still finish the job"); + let elapsed = start.elapsed(); + // SIGTERM grace = 3 s, then SIGKILL + 200 ms; allow some slack. + assert!( + elapsed >= Duration::from_secs(3), + "should wait full SIGTERM grace; was {elapsed:?}" + ); + assert!( + elapsed < Duration::from_secs(5), + "SIGKILL should land within 5s; was {elapsed:?}" + ); + assert!(!process_alive(pid_in_file)); + let _ = child.wait(); + } + + #[test] + fn wait_for_port_free_blocks_then_releases() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let td = fresh_root(); + set_root(td.path()); + let port = pick_port(); + let pf = pidfile_path(); + let mut child = spawn_stub(&pf, port, false); + + // While bound: should time out fast. + let start = Instant::now(); + let freed = wait_for_port_free(port, Duration::from_millis(500)); + assert!(!freed, "port should still be bound"); + assert!(start.elapsed() >= Duration::from_millis(450)); + + kill_pidfile_daemon(); + let _ = child.wait(); + + // After kill: should detect free quickly. + assert!( + wait_for_port_free(port, Duration::from_secs(2)), + "port should free after kill" + ); + } + + #[test] + fn process_alive_correct_for_running_and_dead() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let td = fresh_root(); + set_root(td.path()); + let port = pick_port(); + let pf = pidfile_path(); + let mut child = spawn_stub(&pf, port, false); + let pid = child.id(); + assert!(process_alive(pid)); + + let _ = child.kill(); + let _ = child.wait(); + // Reaped — should now report dead. + assert!(!process_alive(pid)); + } + + #[test] + fn state_atomic_round_trip_under_concurrent_reads() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let td = fresh_root(); + set_root(td.path()); + + // 50 alternating writes from another thread; main thread reads continuously + // and must never observe a torn / non-deserializable file. + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop_clone = stop.clone(); + let writer = std::thread::spawn(move || { + for i in 0..50 { + let mut s = load_state(); + s.installed_hash = Some(format!("hash-{i:08}")); + save_state(&mut s); + std::thread::sleep(Duration::from_millis(2)); + } + stop_clone.store(true, std::sync::atomic::Ordering::Relaxed); + }); + + let mut bad_reads = 0u32; + while !stop.load(std::sync::atomic::Ordering::Relaxed) { + let path = state_path(); + if let Ok(bytes) = std::fs::read(&path) { + if !bytes.is_empty() && serde_json::from_slice::(&bytes).is_err() { + bad_reads += 1; + } + } + } + writer.join().unwrap(); + assert_eq!(bad_reads, 0, "atomic rename should prevent torn reads"); + + let final_state = load_state(); + assert!(final_state + .installed_hash + .as_deref() + .map(|h| h.starts_with("hash-")) + .unwrap_or(false)); + } + + #[test] + fn sha256_detects_content_change_at_same_path() { + let td = fresh_root(); + let p = td.path().join("bin"); + std::fs::write(&p, b"v1-bytes").unwrap(); + let h1 = sha256_file(&p).unwrap(); + std::fs::write(&p, b"v2-different-bytes").unwrap(); + let h2 = sha256_file(&p).unwrap(); + assert_ne!(h1, h2); + // Stable: rewriting same bytes yields same hash. + std::fs::write(&p, b"v1-bytes").unwrap(); + assert_eq!(h1, sha256_file(&p).unwrap()); + } + + #[test] + fn copy_atomic_sets_executable_bit_and_replaces_existing() { + use std::os::unix::fs::PermissionsExt; + let td = fresh_root(); + let src = td.path().join("src"); + let dst = td.path().join("dst"); + std::fs::write(&src, b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::write(&dst, b"old-content").unwrap(); + + copy_atomic(&src, &dst).expect("copy"); + assert_eq!(std::fs::read(&dst).unwrap(), b"#!/bin/sh\necho hi\n"); + let mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o755, "executable bit should be set"); + + // No leftover .tmp file. + assert!(!td.path().join("tmp").exists()); + assert!(!std::fs::read_dir(td.path()).unwrap().any(|e| e + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".tmp"))); + } + + #[test] + fn kill_pidfile_returns_true_when_no_pidfile() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let td = fresh_root(); + set_root(td.path()); + // Pristine config root → no pidfile. + assert!(kill_pidfile_daemon()); + } + + #[test] + fn kill_pidfile_cleans_stale_entry_when_pid_already_dead() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let td = fresh_root(); + set_root(td.path()); + // Spawn + immediately kill so we have a known-dead PID. + let port = pick_port(); + let pf = pidfile_path(); + let mut child = spawn_stub(&pf, port, false); + let dead_pid = child.id(); + let _ = child.kill(); + let _ = child.wait(); + // Re-write the pidfile so kill sees the stale PID (kill_pidfile clears + // it on responsive exit; we intentionally restore it). + std::fs::write(&pf, dead_pid.to_string()).unwrap(); + assert!(kill_pidfile_daemon(), "stale pidfile should be cleaned up"); + assert!(read_pidfile().is_none()); + } +} + +// ─── Linux end-to-end tests of the orchestrator (Scope B) ──────────────────── +// +// These exercise the full ensure_daemon_runtime_ready state machine against a +// Python stub that mimics the contract the orchestrator actually relies on: +// • binds 127.0.0.1:$port +// • writes its PID to $SKILL_DAEMON_CONFIG_ROOT/daemon.pid +// • answers GET /v1/version with PROTOCOL_VERSION=1 +// +// We don't use the real skill-daemon binary because it pulls llama-cpp-sys +// (libclang/bindgen/several minutes of C++ compile) which is out of scope for +// upgrade-flow validation. The orchestrator never inspects daemon behavior +// beyond /v1/version, so a stub gives identical coverage with a 5s test run. +#[cfg(all(test, target_os = "linux"))] +mod orchestrator_linux_e2e { + use super::*; + use std::process::Command; + use std::sync::Mutex; + use std::time::Instant; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + /// Python HTTP stub that satisfies the orchestrator's protocol contract. + /// Reads $SKILL_DAEMON_ADDR + $SKILL_DAEMON_CONFIG_ROOT (set by spawn). + const DAEMON_STUB: &str = r#"#!/usr/bin/env python3 +import os, sys, signal, socket +from http.server import BaseHTTPRequestHandler, HTTPServer + +addr = os.environ.get("SKILL_DAEMON_ADDR", "127.0.0.1:18444") +host, port = addr.rsplit(":", 1); port = int(port) +cfg_root = os.environ.get("SKILL_DAEMON_CONFIG_ROOT", "/tmp") +os.makedirs(cfg_root, exist_ok=True) +with open(os.path.join(cfg_root, "daemon.pid"), "w") as f: + f.write(str(os.getpid())) + +class H(BaseHTTPRequestHandler): + def log_message(self, *a, **k): pass # silence access log + def do_GET(self): + if self.path == "/v1/version": + body = b'{"daemon":"skill-daemon","protocol_version":1,"daemon_version":"stub-1.0"}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404); self.end_headers() + +srv = HTTPServer((host, port), H) +signal.signal(signal.SIGTERM, lambda *_: (srv.shutdown(), srv.server_close(), sys.exit(0))) +srv.serve_forever() +"#; + + fn write_stub_daemon(dir: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let p = dir.join("stub-daemon"); + std::fs::write(&p, DAEMON_STUB).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p + } + + fn pick_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let p = l.local_addr().unwrap().port(); + drop(l); + p + } + + /// Set up an isolated config root + daemon address + auth token. The + /// orchestrator and daemon both honor SKILL_DAEMON_CONFIG_ROOT for state, + /// pidfile, and auth.token. + struct E2eEnv { + _root: tempfile::TempDir, + root_path: PathBuf, + port: u16, + token: String, + } + + impl E2eEnv { + fn new() -> Self { + let _root = tempfile::tempdir().unwrap(); + let root_path = _root.path().to_path_buf(); + let port = pick_port(); + let token = "e2e-test-token".to_string(); + + std::env::set_var("SKILL_DAEMON_CONFIG_ROOT", &root_path); + std::env::set_var("SKILL_DAEMON_ADDR", format!("127.0.0.1:{port}")); + std::env::set_var("SKILL_DAEMON_TOKEN", &token); + // Skip OS-service install in the test (no systemd in container). + std::env::set_var("SKILL_DAEMON_SERVICE_AUTOINSTALL", "0"); + + // Pre-write the auth token at the path the orchestrator reads. + std::fs::write(root_path.join("auth.token"), format!("{token}\n")).unwrap(); + + Self { + _root, + root_path, + port, + token, + } + } + + fn set_bundled(&self, path: &Path) { + std::env::set_var("SKILL_DAEMON_BIN", path); + } + + fn cleanup_daemon(&self) { + // Best-effort: kill whatever the orchestrator left running on our + // port, so the next test doesn't see a stale process. + let _ = kill_pidfile_daemon(); + kill_port_owner(self.port); + // Give kernel time to release the port. + std::thread::sleep(Duration::from_millis(300)); + } + } + + impl Drop for E2eEnv { + fn drop(&mut self) { + self.cleanup_daemon(); + std::env::remove_var("SKILL_DAEMON_CONFIG_ROOT"); + std::env::remove_var("SKILL_DAEMON_ADDR"); + std::env::remove_var("SKILL_DAEMON_TOKEN"); + std::env::remove_var("SKILL_DAEMON_BIN"); + std::env::remove_var("SKILL_DAEMON_SERVICE_AUTOINSTALL"); + } + } + + /// Write a wrapper script (different SHA256, identical behavior) that + /// execs into the stub daemon. Used to simulate "new version installed". + fn write_wrapper(dir: &Path, name: &str, label: &str, stub: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let p = dir.join(name); + std::fs::write( + &p, + format!( + "#!/usr/bin/env bash\n# wrapper: {label}\nexec {} \"$@\"\n", + stub.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p + } + + /// A wrapper that exits 1 immediately — used to simulate a broken + /// upgrade where the new daemon binary fails to come up. + fn write_broken_wrapper(dir: &Path, name: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let p = dir.join(name); + std::fs::write(&p, "#!/usr/bin/env bash\nexit 1\n").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p + } + + fn wait_for_state_phase(deadline: Instant, want: Phase) -> UpgradeState { + loop { + let s = load_state(); + if s.phase == want { + return s; + } + if Instant::now() > deadline { + return s; + } + std::thread::sleep(Duration::from_millis(100)); + } + } + + #[test] + fn fresh_install_sets_state_ready_and_records_hash() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let env = E2eEnv::new(); + let stub = write_stub_daemon(&env.root_path); + + // Bundled = a wrapper around the stub. The wrapper gives us a stable, + // hashable artifact distinct from the stub itself, so swapping + // wrappers later (in_place_upgrade test) reliably triggers an upgrade. + let bundled = write_wrapper(&env.root_path, "bundled", "v1", &stub); + env.set_bundled(&bundled); + let bundled_hash = sha256_file(&bundled).unwrap(); + + crate::daemon_cmds::ensure_daemon_runtime_ready(); + + let state = load_state(); + assert_eq!( + state.phase, + Phase::Ready, + "phase should be Ready on success" + ); + assert_eq!( + state.installed_hash.as_deref(), + Some(bundled_hash.as_str()), + "installed_hash should match the bundled binary" + ); + assert!( + state.rollback_hash.is_some(), + "rollback snapshot should have been written" + ); + + // Daemon really is answering on the configured port. + let url = format!("http://127.0.0.1:{}/v1/version", env.port); + let out = Command::new("curl") + .args([ + "-sf", + "-H", + &format!("Authorization: Bearer {}", env.token), + &url, + ]) + .output() + .unwrap(); + assert!(out.status.success(), "/v1/version should respond"); + let body = String::from_utf8_lossy(&out.stdout); + assert!(body.contains("protocol_version"), "body: {body}"); + } + + #[test] + fn in_place_upgrade_swaps_installed_hash_and_keeps_daemon_alive() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let env = E2eEnv::new(); + let stub = write_stub_daemon(&env.root_path); + + // First launch: v1 wrapper. + let v1 = write_wrapper(&env.root_path, "bundled-v1", "v1", &stub); + env.set_bundled(&v1); + let v1_hash = sha256_file(&v1).unwrap(); + crate::daemon_cmds::ensure_daemon_runtime_ready(); + assert_eq!( + load_state().installed_hash.as_deref(), + Some(v1_hash.as_str()) + ); + + // Second launch: bundled binary content has changed (different label + // → different SHA256). The orchestrator must detect, kill v1, spawn + // v2, and update installed_hash + rollback_hash to v2. + let v2 = write_wrapper(&env.root_path, "bundled-v2", "v2", &stub); + env.set_bundled(&v2); + let v2_hash = sha256_file(&v2).unwrap(); + assert_ne!(v1_hash, v2_hash, "wrappers must hash differently"); + + crate::daemon_cmds::ensure_daemon_runtime_ready(); + + let state = load_state(); + assert_eq!(state.phase, Phase::Ready); + assert_eq!(state.installed_hash.as_deref(), Some(v2_hash.as_str())); + assert_eq!(state.rollback_hash.as_deref(), Some(v2_hash.as_str())); + } + + #[test] + fn broken_bundled_falls_back_to_rollback() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let env = E2eEnv::new(); + let stub = write_stub_daemon(&env.root_path); + + // Phase 1 — establish a known-good rollback snapshot via a fresh install. + let v1 = write_wrapper(&env.root_path, "bundled-v1", "v1", &stub); + env.set_bundled(&v1); + let v1_hash = sha256_file(&v1).unwrap(); + crate::daemon_cmds::ensure_daemon_runtime_ready(); + assert_eq!( + load_state().rollback_hash.as_deref(), + Some(v1_hash.as_str()) + ); + + // Phase 2 — point bundled at a broken script (exit 1). The orchestrator + // should fail twice, then roll back to the v1 snapshot and end Ready. + let broken = write_broken_wrapper(&env.root_path, "bundled-broken"); + env.set_bundled(&broken); + crate::daemon_cmds::ensure_daemon_runtime_ready(); + + let state = wait_for_state_phase(Instant::now() + Duration::from_secs(3), Phase::Ready); + assert_eq!(state.phase, Phase::Ready, "rollback should restore Ready"); + assert_eq!( + state.installed_hash.as_deref(), + Some(v1_hash.as_str()), + "installed_hash should be the rollback snapshot's hash" + ); + // Daemon really is the rolled-back one. + let url = format!("http://127.0.0.1:{}/v1/version", env.port); + let out = Command::new("curl") + .args([ + "-sf", + "-H", + &format!("Authorization: Bearer {}", env.token), + &url, + ]) + .output() + .unwrap(); + assert!(out.status.success()); + } + + #[test] + fn terminal_failure_when_no_rollback_and_bundled_broken() { + // unwrap_or_else recovers a poisoned lock so one panicking test + // doesn't cascade-fail every test that follows. + let _g = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let env = E2eEnv::new(); + + // Bundled is broken from the start AND there is no rollback snapshot. + let broken = write_broken_wrapper(&env.root_path, "bundled-broken"); + env.set_bundled(&broken); + + crate::daemon_cmds::ensure_daemon_runtime_ready(); + + let state = load_state(); + assert_eq!(state.phase, Phase::Failed, "phase should be Failed"); + assert!(state.last_error.is_some(), "last_error should be populated"); + // Nothing should be bound to the port. + assert!(wait_for_port_free(env.port, Duration::from_secs(1))); + } +} diff --git a/src-tauri/src/helpers.rs b/src-tauri/src/helpers.rs index 564f9015..9fdf8d1c 100644 --- a/src-tauri/src/helpers.rs +++ b/src-tauri/src/helpers.rs @@ -11,7 +11,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_notification::NotificationExt; -use crate::settings::{save_secrets_from_settings, settings_path}; +use crate::settings::settings_path; use crate::state::*; use crate::ws_server::WsBroadcaster; use crate::MutexExt; @@ -269,13 +269,14 @@ pub(crate) fn save_settings_now(app: &AppHandle) { // Infrastructure / server config data.ws_host = s.ws_host.clone(); data.ws_port = s.ws_port; - data.api_token = s.api_token.clone(); + // Secrets (api_token, device_api credentials) are owned by the daemon's + // route handlers and stored exclusively in the system keychain — Tauri + // no longer round-trips them through AppState. See keychain::get_*. data.hf_endpoint = s.hf_endpoint.clone(); data.update_check_interval_secs = s.update_check_interval_secs; // Hardware / device config data.openbci = s.openbci_config.clone(); - data.device_api = s.device_api_config.clone(); data.neutts = s.neutts_config.clone(); data.tts_preload = s.tts_preload; data.screenshot = s.screenshot_config.clone(); @@ -304,9 +305,6 @@ pub(crate) fn save_settings_now(app: &AppHandle) { drop(s); - // Persist secrets to the system keychain (encrypted, survives updates). - save_secrets_from_settings(&data); - if let Ok(json) = serde_json::to_string_pretty(&data) { if let Err(e) = std::fs::write(&path, &json) { eprintln!("[settings] save error: {e}"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9616716b..58cc29a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -79,12 +79,14 @@ mod tray; mod about; mod active_window; +mod auto_update; mod shortcut_cmds; mod update_channel; mod window_cmds; mod daemon_cmds; +mod daemon_upgrade; mod label_cmds; mod settings_cmds; @@ -108,6 +110,7 @@ use std::sync::{Arc, Mutex}; use tauri::Manager; use about::{get_about_info, open_about_window}; +use auto_update::{get_auto_update_enabled, set_auto_update_enabled}; use daemon_cmds::{ cancel_session, cancel_weights_download, daemon_install_service, daemon_uninstall_service, estimate_reembed, force_restart_daemon, get_daemon_bootstrap, get_daemon_service_status, @@ -311,6 +314,8 @@ pub fn run() { set_update_channel, channel_check_for_update, channel_download_and_install, + get_auto_update_enabled, + set_auto_update_enabled, pick_ref_wav_file, get_recent_active_windows, get_recent_input_activity, diff --git a/src-tauri/src/llm/cmds/hardware_fit.rs b/src-tauri/src/llm/cmds/hardware_fit.rs index 9ead4a8f..23f0532c 100644 --- a/src-tauri/src/llm/cmds/hardware_fit.rs +++ b/src-tauri/src/llm/cmds/hardware_fit.rs @@ -154,6 +154,7 @@ fn catalog_entry_to_llm_model( moe_intermediate_size: None, vocab_size: None, shared_expert_intermediate_size: None, + architecture: None, } } diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs index b41550a7..09d88baa 100644 --- a/src-tauri/src/setup.rs +++ b/src-tauri/src/setup.rs @@ -391,11 +391,10 @@ fn load_and_apply_settings(app: &mut tauri::App, skill_dir: &std::path::Path) { s.hooks = data.hooks; s.ws_host = data.ws_host.clone(); s.ws_port = data.ws_port; - s.api_token = data.api_token.clone(); + // Secrets stay in the keychain; Tauri no longer caches them in AppState. s.hf_endpoint = data.hf_endpoint.clone(); s.update_check_interval_secs = data.update_check_interval_secs; s.openbci_config = data.openbci; - s.device_api_config = data.device_api; s.scanner_config = data.scanner; s.location_enabled = data.location_enabled; s.inference_device = data.inference_device.clone(); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 24d001a3..c11df45e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -376,16 +376,20 @@ pub struct AppState { // ── Network / services ──────────────────────────────────────────────── pub ws_host: String, pub ws_port: u16, - pub api_token: String, pub hf_endpoint: String, pub update_check_interval_secs: u64, /// Set by the frontend when an update has been downloaded and is ready /// to install on next restart / relaunch. pub update_ready_to_install: bool, + /// Version string of an update detected by the background poller while + /// the auto-update toggle is OFF — surfaces in the tray menu so the + /// user notices a pending update without opening Settings. Cleared + /// when the user installs (`set_update_ready(true)`) or re-enables + /// auto-update. + pub update_available_pending: Option, // ── Device configs ──────────────────────────────────────────────────── pub openbci_config: crate::settings::OpenBciConfig, - pub device_api_config: crate::settings::DeviceApiConfig, pub scanner_config: crate::settings::ScannerConfig, /// Location services enabled by the user (default false). @@ -497,16 +501,15 @@ impl Default for AppState { )), ws_host: default_ws_host(), ws_port: default_ws_port(), - api_token: String::new(), hf_endpoint: skill_settings::default_hf_endpoint(), update_check_interval_secs: default_update_check_interval(), update_ready_to_install: false, + update_available_pending: None, openbci_config: crate::settings::OpenBciConfig::default(), location_enabled: false, inference_device: skill_settings::default_inference_device(), llm_gpu_layers_saved: skill_settings::default_llm_gpu_layers_saved(), exg_inference_device: skill_settings::default_exg_inference_device(), - device_api_config: crate::settings::DeviceApiConfig::default(), scanner_config: crate::settings::ScannerConfig::default(), neutts_config: NeuttsConfig::default(), tts_preload: true, diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 6c067384..8164d115 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -143,6 +143,15 @@ fn structure_key(st: &DeviceStatus, app: &AppHandle) -> String { let llm_downloads = tray_download_fingerprint(app); + // Pending update version (only set when auto-update is OFF). Including it + // in the structure key forces a rebuild that adds/removes the tray hint + // when the background poller flips the state. + let pending_update = { + let r = app.app_state(); + let g = r.lock_or_recover(); + g.update_available_pending.clone().unwrap_or_default() + }; + let mut pair_parts = st .paired_devices .iter() @@ -156,7 +165,7 @@ fn structure_key(st: &DeviceStatus, app: &AppHandle) -> String { let state = st.state.as_str(); format!( - "{state}|{pairs}|{ls}|{ss}|{sets}|{cs}|{hs}|{hist}|{api}|{ts}|{ft}|{chat}|{compare}|{llm_downloads}" + "{state}|{pairs}|{ls}|{ss}|{sets}|{cs}|{hs}|{hist}|{api}|{ts}|{ft}|{chat}|{compare}|{llm_downloads}|{pending_update}" ) } @@ -274,6 +283,25 @@ pub(crate) fn build_menu(app: &AppHandle, st: &DeviceStatus) -> tauri::Result, + )?)?; + } + menu.append(&PredefinedMenuItem::separator(app)?)?; // ── Status info (always present — updated in-place by update_status_items) ── diff --git a/src-tauri/src/tray_setup.rs b/src-tauri/src/tray_setup.rs index 320ee935..060bde83 100644 --- a/src-tauri/src/tray_setup.rs +++ b/src-tauri/src/tray_setup.rs @@ -122,7 +122,7 @@ pub(crate) fn build_tray( }); } else if id == "show_logs" { crate::window_cmds::open_latest_log(); - } else if id == "check_update" { + } else if id == "check_update" || id == "update_available" { let a = app.clone(); tauri::async_runtime::spawn(async move { let _ = crate::window_cmds::open_updates_window(a).await; diff --git a/src-tauri/src/tts.rs b/src-tauri/src/tts.rs index a1544542..ac465a68 100644 --- a/src-tauri/src/tts.rs +++ b/src-tauri/src/tts.rs @@ -51,13 +51,13 @@ pub async fn tts_init(app_handle: AppHandle) -> Result<(), String> { #[tauri::command] pub async fn tts_unload(app_handle: AppHandle) -> Result<(), String> { let result = skill_tts::tts_unload().await.map_err(|e| e.to_string()); - #[cfg(any(feature = "tts-kitten", feature = "tts-neutts"))] + #[cfg(any(tts_kitten_active, feature = "tts-neutts"))] if result.is_ok() { app_handle .emit(TTS_PROGRESS_EVENT, TtsProgressEvent::unloaded()) .ok(); } - #[cfg(not(any(feature = "tts-kitten", feature = "tts-neutts")))] + #[cfg(not(any(tts_kitten_active, feature = "tts-neutts")))] let _ = &app_handle; result } diff --git a/src-tauri/src/window_cmds.rs b/src-tauri/src/window_cmds.rs index f77ed190..19e5732f 100644 --- a/src-tauri/src/window_cmds.rs +++ b/src-tauri/src/window_cmds.rs @@ -46,6 +46,24 @@ impl<'a> Default for WindowSpec<'a> { } } +/// Clamp a requested logical inner size so it fits on the primary monitor. +/// +/// Without this, windows configured larger than the user's screen (e.g. the +/// 880-tall onboarding window on a 13" laptop) open partially off-screen with +/// their footer controls unreachable. +fn clamp_to_monitor(app: &AppHandle, requested: (f64, f64)) -> (f64, f64) { + // Reserve room for menubar/taskbar/dock so the title bar stays visible. + const CHROME_MARGIN: f64 = 80.0; + const FLOOR: f64 = 320.0; + let Ok(Some(monitor)) = app.primary_monitor() else { + return requested; + }; + let scale = monitor.scale_factor(); + let max_w = (monitor.size().width as f64 / scale - CHROME_MARGIN).max(FLOOR); + let max_h = (monitor.size().height as f64 / scale - CHROME_MARGIN).max(FLOOR); + (requested.0.min(max_w), requested.1.min(max_h)) +} + /// Focus an existing window or create a new one from `spec`. /// /// Deduplicates the repeated "check-existing → unminimize/show/focus → or build new" @@ -57,20 +75,23 @@ pub(crate) fn focus_or_create(app: &AppHandle, spec: WindowSpec) -> Result<(), S let _ = win.set_focus(); return Ok(()); } + let (inner_w, inner_h) = clamp_to_monitor(app, spec.inner_size); let mut builder = tauri::WebviewWindowBuilder::new( app, spec.label, tauri::WebviewUrl::App(spec.route.into()), ) .title(spec.title) - .inner_size(spec.inner_size.0, spec.inner_size.1) + .inner_size(inner_w, inner_h) .resizable(spec.resizable) .center() .decorations(false) .transparent(true); if let Some((w, h)) = spec.min_inner_size { - builder = builder.min_inner_size(w, h); + // Min must not exceed the (possibly clamped) inner size, or the + // builder will silently grow the window past the screen. + builder = builder.min_inner_size(w.min(inner_w), h.min(inner_h)); } if spec.always_on_top { builder = builder.always_on_top(true); @@ -102,21 +123,21 @@ pub(crate) fn focus_or_create_with_emit( let _ = win.emit(event, payload.to_string()); return Ok(()); } - // Fall through to normal builder + let (inner_w, inner_h) = clamp_to_monitor(app, spec.inner_size); let mut builder = tauri::WebviewWindowBuilder::new( app, spec.label, tauri::WebviewUrl::App(spec.route.into()), ) .title(spec.title) - .inner_size(spec.inner_size.0, spec.inner_size.1) + .inner_size(inner_w, inner_h) .resizable(spec.resizable) .center() .decorations(false) .transparent(true); if let Some((w, h)) = spec.min_inner_size { - builder = builder.min_inner_size(w, h); + builder = builder.min_inner_size(w.min(inner_w), h.min(inner_h)); } builder @@ -1234,6 +1255,12 @@ pub fn set_update_ready(app: AppHandle, ready: bool) { let r = app.state::>>(); let mut g = r.lock_or_recover(); g.update_ready_to_install = ready; + if ready { + // Once staged, the "⬆ Update available" tray hint is redundant. + g.update_available_pending = None; + drop(g); + crate::tray::refresh_tray(&app); + } } #[tauri::command] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index be59e875..2e8440fa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NeuroSkill", - "version": "0.0.129", + "version": "0.0.131-rc.6", "identifier": "com.neuroskill.skill", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/app.css b/src/app.css index 05bcc5b5..16758bc8 100644 --- a/src/app.css +++ b/src/app.css @@ -45,14 +45,19 @@ --color-surface-2: var(--surface-2); --color-surface-3: var(--surface-3); - /* ── UI type scale ── */ - --text-ui-2xs: 0.5rem; /* 8px — micro metadata, axis labels */ - --text-ui-xs: 0.56rem; /* 9px — section headers, small badges, ON/OFF */ - --text-ui-sm: 0.62rem; /* 10px — descriptions, helper text */ - --text-ui-base: 0.68rem; /* 11px — input labels, chip buttons, status */ - --text-ui-md: 0.75rem; /* 12px — primary form labels */ - --text-ui-lg: 0.82rem; /* 13px — card / section headings */ - --text-ui-xl: 0.95rem; /* 15px — page-level titles */ + /* ── UI type scale ── + Sized for readability on Retina + non-Retina displays. Every step + meets or exceeds the macOS HIG minimum (11pt = 13px) and iOS HIG + minimum (11pt = 14.7px) for legible UI text. The 2xs tier is the + hard floor — used only for micro-metadata where context makes the + content non-essential. Avoid going below 2xs; use 2xs sparingly. */ + --text-ui-2xs: 0.6875rem; /* 11px — micro metadata, axis labels */ + --text-ui-xs: 0.75rem; /* 12px — section headers, small badges, ON/OFF */ + --text-ui-sm: 0.8125rem; /* 13px — descriptions, helper text */ + --text-ui-base: 0.875rem; /* 14px — input labels, chip buttons, status */ + --text-ui-md: 0.9375rem; /* 15px — primary form labels */ + --text-ui-lg: 1rem; /* 16px — card / section headings */ + --text-ui-xl: 1.125rem; /* 18px — page-level titles */ } /* ── Light theme (day) ─────────────────────────────────────────────────────── */ @@ -501,6 +506,11 @@ } .mdr .mdr-code { + /* `inline-block` so vertical padding contributes to line layout — + prevents the bounding box from overlapping adjacent / + siblings on the same line. */ + display: inline-block; + vertical-align: baseline; font-family: ui-monospace, "Cascadia Code", "Fira Code", "Consolas", monospace; font-size: 0.83em; background: oklch(from var(--color-violet-600) l c h / 9%); diff --git a/src/lib/charts/BandChart.svelte b/src/lib/charts/BandChart.svelte index 824bf145..788a4ae2 100644 --- a/src/lib/charts/BandChart.svelte +++ b/src/lib/charts/BandChart.svelte @@ -52,6 +52,7 @@ export interface BandSnapshot { sample_entropy?: number; pac_theta_gamma?: number; laterality_index?: number; + echt?: number; // PPG-derived hr?: number; rmssd?: number; diff --git a/src/lib/charts/ElectrodeGuide.svelte b/src/lib/charts/ElectrodeGuide.svelte index 4b15cfd8..7b06240c 100644 --- a/src/lib/charts/ElectrodeGuide.svelte +++ b/src/lib/charts/ElectrodeGuide.svelte @@ -380,7 +380,7 @@ function qualityBg(val: number): string { : 'text-muted-foreground border-border dark:border-white/[0.06] hover:text-foreground hover:border-foreground/30'}" > {tab.label} - {tab.count()} + {tab.count()} {/each} @@ -409,11 +409,11 @@ function qualityBg(val: number): string { {name} - + {labelToText(label)} - {musePositionLabels[idx]} + {musePositionLabels[idx]} {/each} @@ -487,7 +487,7 @@ function qualityBg(val: number): string {
{#each Object.entries(regionColors) as [region, color]} {#if region !== "reference"} @@ -500,7 +500,7 @@ function qualityBg(val: number): string {
-
Drag to rotate · Click electrode
@@ -515,9 +515,9 @@ function qualityBg(val: number): string { {el.name} {#if el.muse} - Muse + Muse {/if} - {regionLabels[el.region]} + {regionLabels[el.region]} {#if el.muse && effectiveQuality} {@const chIdx = museChannels.indexOf(el.name)} {#if chIdx >= 0} diff --git a/src/lib/charts/InteractiveGraph3D.svelte b/src/lib/charts/InteractiveGraph3D.svelte index ebc839b3..613c3a2a 100644 --- a/src/lib/charts/InteractiveGraph3D.svelte +++ b/src/lib/charts/InteractiveGraph3D.svelte @@ -1097,7 +1097,7 @@ function fmtTs(unix: number) {
-
+
{#each LEGEND_BASE as l}
@@ -1127,7 +1127,7 @@ function fmtTs(unix: number) { Screenshot link
{/if} - + hover · click to highlight · click again to clear
@@ -1135,7 +1135,7 @@ function fmtTs(unix: number) { {#if eegDots.length > 0}
- + EEG node time scale {/each} - + {eegDots.length} EEG point{eegDots.length !== 1 ? "s" : ""} · {fmtTs(eegTimeMin)} → {fmtTs(eegTimeMax)}
{:else} -
+
EEG nodes colored by session time (turbo gradient)
{/if} diff --git a/src/lib/chat/ChatMessageList.svelte b/src/lib/chat/ChatMessageList.svelte index 12b5b280..790610ec 100644 --- a/src/lib/chat/ChatMessageList.svelte +++ b/src/lib/chat/ChatMessageList.svelte @@ -119,7 +119,7 @@ function copyMessage(msg: Message) {

{t("chat.empty.stoppedHint")}

-
+
{#if expanded} diff --git a/src/lib/dashboard/CompositeScores.svelte b/src/lib/dashboard/CompositeScores.svelte index 7c268f0a..b618785c 100644 --- a/src/lib/dashboard/CompositeScores.svelte +++ b/src/lib/dashboard/CompositeScores.svelte @@ -28,7 +28,7 @@ let { meditation, cognitiveLoad, drowsiness }: Props = $props();
- {t(`dashboard.${item.k}`)} + {t(`dashboard.${item.k}`)} {item.v.toFixed(0)}
diff --git a/src/lib/dashboard/ConsciousnessMetrics.svelte b/src/lib/dashboard/ConsciousnessMetrics.svelte index 3ab346c2..820558cd 100644 --- a/src/lib/dashboard/ConsciousnessMetrics.svelte +++ b/src/lib/dashboard/ConsciousnessMetrics.svelte @@ -45,7 +45,7 @@ const items = $derived([
- + {t(`dashboard.consciousness.${item.k}`)}
- /100 + /100
{/each} diff --git a/src/lib/dashboard/EegIndices.svelte b/src/lib/dashboard/EegIndices.svelte index 46ead50f..3db79268 100644 --- a/src/lib/dashboard/EegIndices.svelte +++ b/src/lib/dashboard/EegIndices.svelte @@ -32,6 +32,7 @@ interface Props { se: number; pac: number; lat: number; + echt: number; headache: number; migraine: number; /** @@ -65,6 +66,7 @@ let { se, pac, lat, + echt, headache, migraine, showMu = true, @@ -96,13 +98,14 @@ let { { k: "sampleEntropy", v: se.toFixed(3), c: '#6b7280' }, { k: "pacThetaGamma", v: pac.toFixed(3), c: pac>0.5?'var(--color-violet-500)':'#6b7280', bar: pac*100, bg:'bg-violet-500' }, { k: "lateralityIndex", v: (lat>=0?'+':'')+lat.toFixed(3), c: '#6b7280' }, + { k: "echt", v: echt.toFixed(3), c: echt>0.5?'#22c55e':'#6b7280', bar: echt*100, bg:'bg-emerald-500' }, { k: "headache", v: headache.toFixed(0), c: headache>60?'#f43f5e':headache>30?'#f59e0b':'#22c55e', bar: Math.min(100,headache), grad:'linear-gradient(90deg,#f87171,#ef4444)' }, { k: "migraine", v: migraine.toFixed(0), c: migraine>60?'#f43f5e':migraine>30?'#f59e0b':'#22c55e', bar: Math.min(100,migraine), grad:'linear-gradient(90deg,#fb7185,#f43f5e)' }, ] as item}
- {t(`dashboard.${item.k}`)} + {t(`dashboard.${item.k}`)} {item.v}
{#if item.bar !== undefined} diff --git a/src/lib/dashboard/FaaGauge.svelte b/src/lib/dashboard/FaaGauge.svelte index 559e84aa..9b477b3a 100644 --- a/src/lib/dashboard/FaaGauge.svelte +++ b/src/lib/dashboard/FaaGauge.svelte @@ -38,7 +38,7 @@ let { faa }: Props = $props(); background: linear-gradient(270deg, var(--color-violet-400), var(--color-violet-500))">
{/if}
-
+
{t("dashboard.faaWithdrawal")} {t("dashboard.faaFormula")} {t("dashboard.faaApproach")} diff --git a/src/lib/dashboard/HeadPoseCard.svelte b/src/lib/dashboard/HeadPoseCard.svelte index 704c6e5a..8a8a27aa 100644 --- a/src/lib/dashboard/HeadPoseCard.svelte +++ b/src/lib/dashboard/HeadPoseCard.svelte @@ -24,7 +24,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.pitch")} + {t("dashboard.pitch")} {pitch >= 0 ? "+" : ""}{pitch.toFixed(1)}°
@@ -36,7 +36,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.roll")} + {t("dashboard.roll")} {roll >= 0 ? "+" : ""}{roll.toFixed(1)}°
@@ -48,7 +48,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.stillness")} + {t("dashboard.stillness")} {stillness.toFixed(0)}
@@ -59,7 +59,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.nods")} + {t("dashboard.nods")} {nodCount}
@@ -67,7 +67,7 @@ let { pitch, roll, stillness, nodCount, shakeCount }: Props = $props();
- {t("dashboard.shakes")} + {t("dashboard.shakes")} {shakeCount}
diff --git a/src/lib/dashboard/PpgMetrics.svelte b/src/lib/dashboard/PpgMetrics.svelte index 101082a7..648e3078 100644 --- a/src/lib/dashboard/PpgMetrics.svelte +++ b/src/lib/dashboard/PpgMetrics.svelte @@ -40,7 +40,7 @@ let { hr, rmssd, sdnn, pnn50, lfHf, respRate, spo2, perfIdx, stressIdx }: Props
- {t(`dashboard.${item.k}`)} + {t(`dashboard.${item.k}`)} {item.v}
diff --git a/src/lib/dashboard/SessionDetail.svelte b/src/lib/dashboard/SessionDetail.svelte index 741bc5ea..598372f9 100644 --- a/src/lib/dashboard/SessionDetail.svelte +++ b/src/lib/dashboard/SessionDetail.svelte @@ -48,6 +48,7 @@ export interface SessionMetrics { sample_entropy: number; pac_theta_gamma: number; laterality_index: number; + echt: number; hr: number; rmssd: number; sdnn: number; @@ -102,6 +103,7 @@ export interface EpochRow { se: number; pac: number; lat: number; + echt: number; hr: number; rmssd: number; sdnn: number; @@ -217,10 +219,10 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l}
{item.v} - {t("sd.outOf100")} + {t("sd.outOf100")}
@@ -239,7 +241,7 @@ export interface CsvMetricsResult { {isOpen(id) ? 'rotate-90' : ''}"> - {label} {/snippet} @@ -257,7 +259,7 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l} {item.v}
@@ -285,10 +287,11 @@ export interface CsvMetricsResult { { l: t("sd.muSupp"), v: m.mu_suppression.toFixed(3), tip: t("tip.muSuppression") }, { l: t("sd.laterality"),v: m.laterality_index.toFixed(3), tip: t("tip.lateralityIndex") }, { l: t("sd.pac"), v: m.pac_theta_gamma.toFixed(3), tip: t("tip.pacThetaGamma") }, + { l: t("sd.echt"), v: m.echt.toFixed(3), tip: t("tip.echt") }, ] as item}
- {item.l} + {item.l} {item.v}
@@ -311,7 +314,7 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l} {item.v}
@@ -337,7 +340,7 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l} {item.v}
@@ -362,7 +365,7 @@ export interface CsvMetricsResult { ] as item}
- {item.l} + {item.l} {item.v}
@@ -452,6 +455,7 @@ export interface CsvMetricsResult { { key: "mood", label: "Mood", color: C_MOOD, data: ts.map(r => r.mood) }, { key: "lat", label: "Laterality", color: C_DELTA, data: ts.map(r => r.lat) }, { key: "pac", label: "PAC θ-γ", color: C_BLINK, data: ts.map(r => r.pac) }, + { key: "echt", label: "ECHT", color: C_ALPHA, data: ts.map(r => r.echt) }, ]} />
{/if} diff --git a/src/lib/dashboard/TimeSeriesChart.svelte b/src/lib/dashboard/TimeSeriesChart.svelte index 5a3d3714..702f77a3 100644 --- a/src/lib/dashboard/TimeSeriesChart.svelte +++ b/src/lib/dashboard/TimeSeriesChart.svelte @@ -744,7 +744,7 @@ onDestroy(() => {
{#if yLabel} - {yLabel} + {yLabel} {/if}
@@ -758,7 +758,7 @@ onDestroy(() => { ondblclick={onDblClick}> {#if zoomXMin !== undefined} diff --git a/src/lib/exg/ExgModelPickerSection.svelte b/src/lib/exg/ExgModelPickerSection.svelte index 6a16f216..344c7093 100644 --- a/src/lib/exg/ExgModelPickerSection.svelte +++ b/src/lib/exg/ExgModelPickerSection.svelte @@ -48,6 +48,8 @@ interface ExgModelConfig { model_backend: string; luna_variant: string; luna_hf_repo: string; + eegdino_variant: string; + eegdino_hf_repo: string; } interface EegModelStatus { encoder_loaded: boolean; @@ -82,6 +84,7 @@ const selectedModel = $derived(catalog?.models.find((m) => m.family === selected function familyToBackend(id: string): string { if (id === "zuna") return "zuna"; if (id.startsWith("luna-")) return "luna"; + if (id.startsWith("eegdino-")) return "eegdino"; if (id === "reve-base" || id === "reve-large") return "reve"; if (id === "cbramod") return "cbramod"; if (id === "eegpt") return "eegpt"; @@ -104,6 +107,10 @@ const activeFamilyId = $derived.by(() => { const variant = modelConfig.luna_variant; return `luna-${variant}`; } + if (backend === "eegdino") { + const variant = modelConfig.eegdino_variant; + return `eegdino-${variant}`; + } if (backend === "zuna") return "zuna"; // For other backends find matching family by backend name for (const [id, _fam] of Object.entries(catalog.families)) { @@ -162,6 +169,13 @@ async function selectModel() { luna_variant: variant, luna_hf_repo: selectedFamily.repo, }); + } else if (backend === "eegdino") { + const variant = id.replace("eegdino-", ""); + await onSaveConfig({ + model_backend: "eegdino", + eegdino_variant: variant, + eegdino_hf_repo: selectedFamily.repo, + }); } else { await onSaveConfig({ model_backend: backend, @@ -182,6 +196,13 @@ async function pickLocalWeights() { luna_variant: variant, luna_hf_repo: `local:${file}`, }); + } else if (backend === "eegdino") { + const variant = selectedFamilyId.replace("eegdino-", ""); + await onSaveConfig({ + model_backend: "eegdino", + eegdino_variant: variant, + eegdino_hf_repo: `local:${file}`, + }); } else { await onSaveConfig({ model_backend: backend, diff --git a/src/lib/generated/settings-search-index.de.json b/src/lib/generated/settings-search-index.de.json index d8d37c27..97e39671 100644 --- a/src/lib/generated/settings-search-index.de.json +++ b/src/lib/generated/settings-search-index.de.json @@ -71,6 +71,30 @@ "label": "Schnellvoreinstellungen", "desc": "Wählen Sie eine Kalibrierungskonfiguration basierend auf Ihrem Ziel, Alter und Anwendungsfall." }, + { + "tab": "embeddings", + "key": "embeddings.backend", + "label": "Embedding-Laufzeit", + "desc": "Wählen Sie das Backend für Text-Embeddings. FastEmbed nutzt ORT; RLX führt dieselben Embedding-Graphen über die lokale RLX-Laufzeit aus, wenn der Daemon mit RLX-Unterstützung gebaut wurde." + }, + { + "tab": "embeddings", + "key": "embeddings.backendFastembed", + "label": "FastEmbed / ORT (Standard)", + "desc": "Standard, kompatibel mit jedem aufgeführten Modell." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend", + "label": "Label-Suchindex", + "desc": "Wählen Sie, welcher lokale Vektorindex die semantische Labelsuche antreibt. Erstellen Sie beide, benchmarken Sie mit Ihrer eigenen Abfrage und wählen Sie dann die schnellere oder qualitativ bessere Option für Ihre Daten." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend.turboquant", + "label": "TurboQuant-Index", + "desc": "Komprimierter TurboVec-Index. Geringerer Speicher- und Plattenverbrauch, gut für große Label-Sammlungen." + }, { "tab": "embeddings", "key": "embeddings.model", @@ -306,6 +330,12 @@ "label": "Encoder-Threads", "desc": "CPU-Threads für den Bild-/Audio-Encoder." }, + { + "tab": "llm", + "key": "llm.mtp.draftTokens", + "label": "Entwurfstoken", + "desc": "Anzahl der spekulativ generierten Token pro Dekodierungsschritt. Höhere Werte steigern den Durchsatz, benötigen aber mehr Speicher. Erfordert ein MTP-fähiges Modell." + }, { "tab": "llm", "key": "llm.size", @@ -833,6 +863,12 @@ "label": "Bei Anmeldung starten", "desc": "Startet automatisch, wenn du dich an deinem Computer anmeldest." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Updates automatisch installieren", + "desc": "Neue Versionen im Hintergrund herunterladen und beim nächsten Neustart installieren. Deaktivieren, um den Zeitpunkt selbst zu wählen." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.en.json b/src/lib/generated/settings-search-index.en.json index 631bd1da..cbd33b82 100644 --- a/src/lib/generated/settings-search-index.en.json +++ b/src/lib/generated/settings-search-index.en.json @@ -71,6 +71,30 @@ "label": "Quick Presets", "desc": "Select a calibration configuration based on your goal, age, and use case. Settings can still be adjusted below." }, + { + "tab": "embeddings", + "key": "embeddings.backend", + "label": "Embedding Runtime", + "desc": "Choose the execution backend for text embeddings. FastEmbed uses ORT; RLX runs the same embedding graphs through the local RLX runtime when this daemon was built with RLX support." + }, + { + "tab": "embeddings", + "key": "embeddings.backendFastembed", + "label": "FastEmbed / ORT", + "desc": "Default, compatible with every listed model." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend", + "label": "Label Search Index", + "desc": "Choose which local vector index powers label semantic search. Build both, benchmark with your own query, then pick the faster or higher-quality option for your data." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend.turboquant", + "label": "TurboQuant", + "desc": "Compressed TurboVec index. Lower memory and disk use, good for large label sets." + }, { "tab": "embeddings", "key": "embeddings.model", @@ -306,6 +330,12 @@ "label": "Encoder threads", "desc": "CPU threads for the vision/audio encoder." }, + { + "tab": "llm", + "key": "llm.mtp.draftTokens", + "label": "Draft tokens", + "desc": "Number of tokens to speculatively draft per decode step. Higher values increase throughput but require more memory. Requires an MTP-enabled model." + }, { "tab": "llm", "key": "llm.size", @@ -833,6 +863,12 @@ "label": "Launch at Login", "desc": "Start automatically when you log in to your computer." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Install updates automatically", + "desc": "Download new versions in the background and install them on the next restart. Turn off to choose when to install." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.es.json b/src/lib/generated/settings-search-index.es.json index 74948814..c5339d6a 100644 --- a/src/lib/generated/settings-search-index.es.json +++ b/src/lib/generated/settings-search-index.es.json @@ -71,6 +71,30 @@ "label": "Preajustes rápidos", "desc": "Seleccione una configuración de calibración según su objetivo, edad y caso de uso. La configuración aún se puede ajustar a continuación." }, + { + "tab": "embeddings", + "key": "embeddings.backend", + "label": "Runtime de embeddings", + "desc": "Elige el backend de ejecución para embeddings de texto. FastEmbed usa ORT; RLX ejecuta los mismos grafos de embeddings mediante el runtime local de RLX cuando el daemon se compiló con soporte RLX." + }, + { + "tab": "embeddings", + "key": "embeddings.backendFastembed", + "label": "FastEmbed / ORT (predeterminado)", + "desc": "Predeterminado, compatible con todos los modelos de la lista." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend", + "label": "Índice de búsqueda de etiquetas", + "desc": "Elige qué índice vectorial local impulsa la búsqueda semántica de etiquetas. Crea ambos, compara con tu propia consulta y luego elige la opción más rápida o de mayor calidad para tus datos." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend.turboquant", + "label": "Índice TurboQuant", + "desc": "Índice TurboVec comprimido. Menor uso de memoria y disco, bueno para conjuntos grandes de etiquetas." + }, { "tab": "embeddings", "key": "embeddings.model", @@ -306,6 +330,12 @@ "label": "Hilos del codificador", "desc": "Hilos de CPU para el codificador de visión/audio." }, + { + "tab": "llm", + "key": "llm.mtp.draftTokens", + "label": "Tokens de borrador", + "desc": "Número de tokens generados especulativamente por paso de decodificación. Valores más altos aumentan el rendimiento pero requieren más memoria. Requiere un modelo con MTP habilitado." + }, { "tab": "llm", "key": "llm.size", @@ -833,6 +863,12 @@ "label": "Iniciar sesión", "desc": "Se inicia automáticamente cuando inicia sesión en su computadora." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Instalar actualizaciones automáticamente", + "desc": "Descarga las nuevas versiones en segundo plano e instálalas al reiniciar. Desactiva esta opción para elegir cuándo instalar." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.fr.json b/src/lib/generated/settings-search-index.fr.json index 8c90297f..e17ea2fe 100644 --- a/src/lib/generated/settings-search-index.fr.json +++ b/src/lib/generated/settings-search-index.fr.json @@ -71,6 +71,30 @@ "label": "Préréglages rapides", "desc": "Sélectionnez une configuration de calibration selon votre objectif, âge et cas d'usage." }, + { + "tab": "embeddings", + "key": "embeddings.backend", + "label": "Runtime d'embeddings", + "desc": "Choisissez le backend d'exécution pour les embeddings texte. FastEmbed utilise ORT ; RLX exécute les mêmes graphes d'embedding via le runtime RLX local lorsque le daemon a été compilé avec le support RLX." + }, + { + "tab": "embeddings", + "key": "embeddings.backendFastembed", + "label": "FastEmbed / ORT (par défaut)", + "desc": "Par défaut, compatible avec tous les modèles listés." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend", + "label": "Index de recherche des labels", + "desc": "Choisissez l'index vectoriel local qui alimente la recherche sémantique de labels. Construisez les deux, comparez avec votre propre requête, puis choisissez l'option la plus rapide ou la plus qualitative pour vos données." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend.turboquant", + "label": "Index TurboQuant", + "desc": "Index TurboVec compressé. Utilise moins de mémoire et d'espace disque, adapté aux grands ensembles de labels." + }, { "tab": "embeddings", "key": "embeddings.model", @@ -306,6 +330,12 @@ "label": "Fils d'encodeur", "desc": "Threads CPU pour l'encodeur vision/audio." }, + { + "tab": "llm", + "key": "llm.mtp.draftTokens", + "label": "Tokens brouillon", + "desc": "Nombre de tokens générés spéculativement par étape de décodage. Des valeurs plus élevées augmentent le débit mais nécessitent plus de mémoire. Nécessite un modèle compatible MTP." + }, { "tab": "llm", "key": "llm.size", @@ -833,6 +863,12 @@ "label": "Lancer à la connexion", "desc": "Démarre automatiquement quand vous ouvrez une session." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Installer les mises à jour automatiquement", + "desc": "Télécharger les nouvelles versions en arrière-plan et les installer au prochain redémarrage. Désactivez pour choisir quand installer." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.he.json b/src/lib/generated/settings-search-index.he.json index d0eb849b..ffbb93eb 100644 --- a/src/lib/generated/settings-search-index.he.json +++ b/src/lib/generated/settings-search-index.he.json @@ -71,6 +71,30 @@ "label": "הגדרות מוגדרות מראש", "desc": "בחר תצורת כיול לפי המטרה, הגיל ומקרה השימוש שלך." }, + { + "tab": "embeddings", + "key": "embeddings.backend", + "label": "זמן ריצה להטמעות", + "desc": "בחר את backend הביצוע להטמעות טקסט. FastEmbed משתמש ב-ORT; ‏RLX מריץ את אותם גרפי הטמעה דרך זמן הריצה המקומי של RLX כאשר הדמון נבנה עם תמיכת RLX." + }, + { + "tab": "embeddings", + "key": "embeddings.backendFastembed", + "label": "FastEmbed / ORT (ברירת מחדל)", + "desc": "ברירת המחדל, תואם לכל מודל ברשימה." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend", + "label": "אינדקס חיפוש תוויות", + "desc": "בחר איזה אינדקס וקטורי מקומי מפעיל את החיפוש הסמנטי בתוויות. בנה את שניהם, הרץ benchmark עם שאילתה משלך, ואז בחר את האפשרות המהירה או האיכותית יותר עבור הנתונים שלך." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend.turboquant", + "label": "אינדקס TurboQuant", + "desc": "אינדקס TurboVec דחוס. שימוש נמוך יותר בזיכרון ובדיסק, טוב לאוספי תוויות גדולים." + }, { "tab": "embeddings", "key": "embeddings.model", @@ -306,6 +330,12 @@ "label": "אשכולות מקודד", "desc": "אשכולות CPU עבור מקודד הוויז'ן/אודיו." }, + { + "tab": "llm", + "key": "llm.mtp.draftTokens", + "label": "אסימוני טיוטה", + "desc": "מספר האסימונים שנוצרים בצורה ספקולטיבית בכל שלב פענוח. ערכים גבוהים יותר מגבירים את התפוקה אך דורשים יותר זיכרון. מחייב מודל עם תמיכה ב-MTP." + }, { "tab": "llm", "key": "llm.size", @@ -833,6 +863,12 @@ "label": "הפעלה בכניסה למערכת", "desc": "מתחיל אוטומטית כשנכנסים למחשב." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "התקן עדכונים אוטומטית", + "desc": "הורד גרסאות חדשות ברקע והתקן בהפעלה הבאה. כבה כדי לבחור מתי להתקין." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.ja.json b/src/lib/generated/settings-search-index.ja.json index f9d39625..45298a6f 100644 --- a/src/lib/generated/settings-search-index.ja.json +++ b/src/lib/generated/settings-search-index.ja.json @@ -71,6 +71,30 @@ "label": "クイックプリセット", "desc": "目的、年齢、用途に基づいたキャリブレーション設定を選択してください。下記で設定を調整できます。" }, + { + "tab": "embeddings", + "key": "embeddings.backend", + "label": "埋め込みランタイム", + "desc": "テキスト埋め込みの実行バックエンドを選びます。FastEmbed は ORT を使い、RLX はデーモンが RLX 対応でビルドされている場合に同じ埋め込みグラフをローカル RLX ランタイムで実行します。" + }, + { + "tab": "embeddings", + "key": "embeddings.backendFastembed", + "label": "FastEmbed / ORT(既定)", + "desc": "既定。一覧のすべてのモデルに対応します。" + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend", + "label": "ラベル検索インデックス", + "desc": "ラベル意味検索に使うローカルベクトルインデックスを選びます。両方構築し、自分のクエリでベンチマークして、データに合った高速または高品質な方を選んでください。" + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend.turboquant", + "label": "TurboQuant インデックス", + "desc": "圧縮 TurboVec インデックス。メモリとディスク使用量が少なく、大量ラベル向け。" + }, { "tab": "embeddings", "key": "embeddings.model", @@ -306,6 +330,12 @@ "label": "エンコーダースレッド", "desc": "ビジョン/オーディオエンコーダー用のCPUスレッド数。" }, + { + "tab": "llm", + "key": "llm.mtp.draftTokens", + "label": "ドラフトトークン数", + "desc": "デコードステップごとに投機的に生成するトークン数。値が大きいほどスループットが向上しますが、メモリをより多く消費します。MTP対応モデルが必要です。" + }, { "tab": "llm", "key": "llm.size", @@ -833,6 +863,12 @@ "label": "ログイン時に起動", "desc": "コンピューターにログインしたときに自動的に起動します。" }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "アップデートを自動的にインストール", + "desc": "新しいバージョンをバックグラウンドでダウンロードし、次回の再起動時にインストールします。タイミングを自分で選ぶには無効にしてください。" + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.ko.json b/src/lib/generated/settings-search-index.ko.json index 84794b7d..eca68ff9 100644 --- a/src/lib/generated/settings-search-index.ko.json +++ b/src/lib/generated/settings-search-index.ko.json @@ -71,6 +71,30 @@ "label": "빠른 프리셋", "desc": "목적, 연령, 사용 사례에 맞는 캘리브레이션 구성을 선택하세요. 아래에서 설정을 추가로 조정할 수 있습니다." }, + { + "tab": "embeddings", + "key": "embeddings.backend", + "label": "임베딩 런타임", + "desc": "텍스트 임베딩 실행 백엔드를 선택합니다. FastEmbed는 ORT를 사용하고, RLX는 데몬이 RLX 지원으로 빌드된 경우 같은 임베딩 그래프를 로컬 RLX 런타임에서 실행합니다." + }, + { + "tab": "embeddings", + "key": "embeddings.backendFastembed", + "label": "FastEmbed / ORT (기본값)", + "desc": "기본값이며 목록의 모든 모델과 호환됩니다." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend", + "label": "라벨 검색 인덱스", + "desc": "라벨 의미 검색에 사용할 로컬 벡터 인덱스를 선택합니다. 둘 다 구축한 뒤 자신의 쿼리로 벤치마크하고, 데이터에 맞는 더 빠르거나 품질이 높은 옵션을 고르세요." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend.turboquant", + "label": "TurboQuant 인덱스", + "desc": "압축된 TurboVec 인덱스. 메모리와 디스크 사용량이 적어 대량 라벨에 적합." + }, { "tab": "embeddings", "key": "embeddings.model", @@ -306,6 +330,12 @@ "label": "인코더 스레드", "desc": "비전/오디오 인코더의 CPU 스레드." }, + { + "tab": "llm", + "key": "llm.mtp.draftTokens", + "label": "드래프트 토큰", + "desc": "디코딩 단계마다 투기적으로 생성할 토큰 수입니다. 값이 높을수록 처리량이 증가하지만 메모리를 더 많이 사용합니다. MTP 지원 모델이 필요합니다." + }, { "tab": "llm", "key": "llm.size", @@ -833,6 +863,12 @@ "label": "로그인 시 시작", "desc": "컴퓨터에 로그인할 때 자동으로 시작합니다." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "업데이트 자동 설치", + "desc": "백그라운드에서 새 버전을 다운로드하고 다음 재시작 시 설치합니다. 설치 시점을 직접 선택하려면 끄세요." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.uk.json b/src/lib/generated/settings-search-index.uk.json index 2877b856..743e736e 100644 --- a/src/lib/generated/settings-search-index.uk.json +++ b/src/lib/generated/settings-search-index.uk.json @@ -71,6 +71,30 @@ "label": "Швидкі пресети", "desc": "Виберіть конфігурацію калібрування відповідно до вашої мети, віку та сфери використання." }, + { + "tab": "embeddings", + "key": "embeddings.backend", + "label": "Рантайм ембедингів", + "desc": "Виберіть backend виконання для текстових ембедингів. FastEmbed використовує ORT; RLX запускає ті самі графи ембедингів через локальний рантайм RLX, якщо демон зібрано з підтримкою RLX." + }, + { + "tab": "embeddings", + "key": "embeddings.backendFastembed", + "label": "FastEmbed / ORT (типово)", + "desc": "Типово, сумісно з усіма моделями у списку." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend", + "label": "Індекс пошуку міток", + "desc": "Виберіть, який локальний векторний індекс обслуговує семантичний пошук міток. Побудуйте обидва, порівняйте на власному запиті, а потім виберіть швидший або якісніший варіант для ваших даних." + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend.turboquant", + "label": "Індекс TurboQuant", + "desc": "Стиснений індекс TurboVec. Менше використання пам'яті та диска, добре для великих наборів міток." + }, { "tab": "embeddings", "key": "embeddings.model", @@ -306,6 +330,12 @@ "label": "Потоки енкодера", "desc": "Потоки CPU для візуального/аудіо-енкодера." }, + { + "tab": "llm", + "key": "llm.mtp.draftTokens", + "label": "Токени чернетки", + "desc": "Кількість токенів, що генеруються спекулятивно на кожному кроці декодування. Більші значення підвищують пропускну здатність, але потребують більше пам'яті. Потребує MTP-сумісну модель." + }, { "tab": "llm", "key": "llm.size", @@ -833,6 +863,12 @@ "label": "Запуск під час входу", "desc": "Запускається автоматично при вході в систему." }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "Встановлювати оновлення автоматично", + "desc": "Завантажувати нові версії у фоні та встановлювати під час наступного перезапуску. Вимкніть, щоб обирати момент встановлення вручну." + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-index.zh.json b/src/lib/generated/settings-search-index.zh.json index e1a41fb8..cef7f568 100644 --- a/src/lib/generated/settings-search-index.zh.json +++ b/src/lib/generated/settings-search-index.zh.json @@ -71,6 +71,30 @@ "label": "快速预设", "desc": "根据您的目标、年龄和使用场景选择校准配置。设置仍可在下方调整。" }, + { + "tab": "embeddings", + "key": "embeddings.backend", + "label": "嵌入运行时", + "desc": "选择文本嵌入的执行后端。FastEmbed 使用 ORT;如果守护进程构建时启用了 RLX,RLX 会通过本地 RLX 运行时执行相同的嵌入图。" + }, + { + "tab": "embeddings", + "key": "embeddings.backendFastembed", + "label": "FastEmbed / ORT(默认)", + "desc": "默认选项,兼容列表中的所有模型。" + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend", + "label": "标签搜索索引", + "desc": "选择为标签语义搜索提供支持的本地向量索引。可先构建两者,用自己的查询做基准测试,再为数据选择更快或更高质量的方案。" + }, + { + "tab": "embeddings", + "key": "embeddings.indexBackend.turboquant", + "label": "TurboQuant 索引", + "desc": "压缩的 TurboVec 索引。内存和磁盘占用更低,适合大量标签。" + }, { "tab": "embeddings", "key": "embeddings.model", @@ -306,6 +330,12 @@ "label": "编码器线程数", "desc": "视觉/音频编码器的 CPU 线程数。" }, + { + "tab": "llm", + "key": "llm.mtp.draftTokens", + "label": "草稿令牌数", + "desc": "每个解码步骤投机生成的令牌数。值越大吞吐量越高,但需要更多内存。需要支持 MTP 的模型。" + }, { "tab": "llm", "key": "llm.size", @@ -833,6 +863,12 @@ "label": "登录时启动", "desc": "登录计算机时自动启动。" }, + { + "tab": "updates", + "key": "updates.autoUpdate", + "label": "自动安装更新", + "desc": "在后台下载新版本,并在下次重启时安装。关闭后可自行选择安装时机。" + }, { "tab": "updates", "key": "updates.checkInterval", diff --git a/src/lib/generated/settings-search-manifest.json b/src/lib/generated/settings-search-manifest.json index 9404325c..4fb073d1 100644 --- a/src/lib/generated/settings-search-manifest.json +++ b/src/lib/generated/settings-search-manifest.json @@ -10,5 +10,5 @@ "uk", "zh" ], - "entriesPerLocale": 142 + "entriesPerLocale": 148 } \ No newline at end of file diff --git a/src/lib/history/HistoryCalendar.svelte b/src/lib/history/HistoryCalendar.svelte index c7577752..0b2e29e7 100644 --- a/src/lib/history/HistoryCalendar.svelte +++ b/src/lib/history/HistoryCalendar.svelte @@ -100,14 +100,14 @@ let {
-
+
{#each ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as month, i} {month} {/each}
-
+
{t("history.heatmap.less")} {#each [0,1,2,3,4] as level}
{#each ["S","M","T","W","T","F","S"] as wd} -
{wd}
+
{wd}
{/each}
@@ -171,7 +171,7 @@ let {
{#each [0,3,6,9,12,15,18,21] as hr} -
+
{hr.toString().padStart(2,"0")}:00
{/each} diff --git a/src/lib/history/HistoryStatsBar.svelte b/src/lib/history/HistoryStatsBar.svelte index a53221e9..974ad3e3 100644 --- a/src/lib/history/HistoryStatsBar.svelte +++ b/src/lib/history/HistoryStatsBar.svelte @@ -43,16 +43,16 @@ let { daysCount, totalHours, recordingStreak, historyStats, weekTrend }: Props =
{daysCount} - {t("history.days")} + {t("history.days")}
{#if historyStats}
{totalHours.toFixed(1)} - {t("history.hours")} + {t("history.hours")}
{historyStats.total_sessions} - {t("history.sessions")} + {t("history.sessions")}
{#if weekTrend && (weekTrend.thisWeek > 0 || weekTrend.lastWeek > 0)}
@@ -65,7 +65,7 @@ let { daysCount, totalHours, recordingStreak, historyStats, weekTrend }: Props = {/if}
- {t("history.thisWeek")} + {t("history.thisWeek")}
{/if} {/if} diff --git a/src/lib/history/SessionMap.svelte b/src/lib/history/SessionMap.svelte index fb499b95..fc0f43ea 100644 --- a/src/lib/history/SessionMap.svelte +++ b/src/lib/history/SessionMap.svelte @@ -296,7 +296,7 @@ $effect(() => { aria-label="Session location map" >
{#if usingIpFallback} -

+

📍 Approximate location{ipCity ? ` · ${ipCity}` : ""} · no GPS recorded for this session

{/if} diff --git a/src/lib/history/history-helpers.ts b/src/lib/history/history-helpers.ts index 0ebe5588..22cf735f 100644 --- a/src/lib/history/history-helpers.ts +++ b/src/lib/history/history-helpers.ts @@ -26,6 +26,14 @@ export interface SessionEntry { file_size_bytes: number; /** Average signal-to-noise ratio (dB) for the session. `null` for very old sessions. */ avg_snr_db: number | null; + /** + * Number of underlying rollover chunks merged into this entry. `1` for + * ordinary single-chunk sessions; `>1` when adjacent same-device chunks + * were collapsed into one logical session by the backend. + */ + chunk_count?: number; + /** CSV paths of every chunk in this logical session, oldest first. */ + chunks?: string[]; } export interface HistoryStatsData { diff --git a/src/lib/i18n/de/dashboard.ts b/src/lib/i18n/de/dashboard.ts index df840f6a..54f45638 100644 --- a/src/lib/i18n/de/dashboard.ts +++ b/src/lib/i18n/de/dashboard.ts @@ -54,6 +54,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Stichpr.-Entropie", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "Lateralität", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG-Metriken", "dashboard.hr": "Herzfrequenz", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/de/history.ts b/src/lib/i18n/de/history.ts index 8e734df8..fa834981 100644 --- a/src/lib/i18n/de/history.ts +++ b/src/lib/i18n/de/history.ts @@ -66,6 +66,7 @@ const history: Record = { "compare.sampleEntropy": "Stichprobenentropie", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "Lateralitätsindex", + "compare.echt": "ECHT (Alpha-Rhythmizität)", "compare.hr": "Herzfrequenz", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", @@ -144,6 +145,7 @@ const history: Record = { "sd.muSupp": "Mu Unterdr.", "sd.laterality": "Lateralität", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth Akt.", "sd.hjorthMob": "Hjorth Mob.", "sd.hjorthCmpl": "Hjorth Kompl.", @@ -196,6 +198,8 @@ const history: Record = { "history.samples": "Abtastwerte", "history.device": "Gerät", "history.battery": "Batterie", + "history.chunkCount": "{n} Abschnitte", + "history.chunkCountTooltip": "Lange Aufnahme zur Absturzsicherung in {n} Dateien aufgeteilt; Dauer {duration}", "history.snr": "Signalqualität", "history.label": "Label", "history.labels": "Labels", diff --git a/src/lib/i18n/de/llm.ts b/src/lib/i18n/de/llm.ts index 76b0c8ae..0f87a9e6 100644 --- a/src/lib/i18n/de/llm.ts +++ b/src/lib/i18n/de/llm.ts @@ -81,6 +81,7 @@ const llm: Record = { "llm.section.models": "Sprachmodelle", "llm.section.mmproj": "Multimodale Projektoren", "llm.section.inference": "Inferenzeinstellungen", + "llm.section.mtp": "Multi-Token-Vorhersage", "llm.enabled": "LLM-Server aktivieren", "llm.enabledDesc": "Führen Sie einen OpenAI-kompatiblen Inferenzserver auf demselben Port aus wie die WebSocket-API. Erfordert die llm Cargo-Funktion und ein heruntergeladenes Modell.", @@ -277,6 +278,11 @@ const llm: Record = { "llm.inference.offloadKqv": "KQV auf GPU auslagern", "llm.inference.offloadKqvDesc": "K/Q/V-Tensoroperationen auf die GPU auslagern, auch wenn nicht alle Schichten GPU-offloaded sind.", + + "llm.mtp.draftTokens": "Entwurfstoken", + "llm.mtp.draftTokensDesc": + "Anzahl der spekulativ generierten Token pro Dekodierungsschritt. Höhere Werte steigern den Durchsatz, benötigen aber mehr Speicher. Erfordert ein MTP-fähiges Modell.", + "llm.hfSearch.title": "HuggingFace-Modelle durchsuchen", "llm.hfSearch.placeholder": "GGUF-Modelle auf HuggingFace suchen…", "llm.hfSearch.searchBtn": "Suchen", @@ -482,6 +488,12 @@ const llm: Record = { "model.idleReembedIdle": "Warte auf Leerlaufzeit", "search.eegCoverage": "EEG-Abdeckung", "search.eegCoverageLabel": "{embedded} von {total} ({pct} %)", + + "model.idleReembedMemoryThrottled": "Verschoben — Systemspeicher bei {pct}% (Limit {limit}%)", + "model.maxResidentMemory": "Maximaler Systemspeicher", + "model.maxResidentMemoryDesc": + "Hintergrund-Embedding überspringen, wenn der Systemspeicher diesen Anteil überschreitet. 100% deaktiviert die Begrenzung.", + "model.maxResidentMemoryDisabled": "aus", }; export default llm; diff --git a/src/lib/i18n/de/search.ts b/src/lib/i18n/de/search.ts index c8c70bf1..a05e2374 100644 --- a/src/lib/i18n/de/search.ts +++ b/src/lib/i18n/de/search.ts @@ -9,6 +9,43 @@ const search: Record = { "embeddings.model": "Einbettungsmodell", "embeddings.modelApplied": "Einbettungsmodell angewendet", "embeddings.modelFailed": "Modell konnte nicht angewendet werden", + "embeddings.backend": "Embedding-Laufzeit", + "embeddings.backendDesc": + "Wählen Sie das Backend für Text-Embeddings. FastEmbed nutzt ORT; RLX führt dieselben Embedding-Graphen über die lokale RLX-Laufzeit aus, wenn der Daemon mit RLX-Unterstützung gebaut wurde.", + "embeddings.backendFastembed": "FastEmbed / ORT (Standard)", + "embeddings.backendFastembedDesc": "Standard, kompatibel mit jedem aufgeführten Modell.", + "embeddings.backendRlx": "RLX", + "embeddings.backendRlxDesc": "Experimenteller, schnellerer Pfad für Safetensors-BERT/Nomic-Modelle.", + "embeddings.indexBackend": "Label-Suchindex", + "embeddings.indexBackendDesc": + "Wählen Sie, welcher lokale Vektorindex die semantische Labelsuche antreibt. Erstellen Sie beide, benchmarken Sie mit Ihrer eigenen Abfrage und wählen Sie dann die schnellere oder qualitativ bessere Option für Ihre Daten.", + "embeddings.indexBackend.hnsw": "HNSW", + "embeddings.indexBackend.hnswDesc": "Aktueller Standard. Hohe Trefferqualität, größerer Graph im Arbeitsspeicher.", + "embeddings.indexBackend.turboquant": "TurboQuant-Index", + "embeddings.indexBackend.turboquantDesc": + "Komprimierter TurboVec-Index. Geringerer Speicher- und Plattenverbrauch, gut für große Label-Sammlungen.", + "embeddings.indexCurrent": "Aktueller Such-Backend: {backend}", + "embeddings.indexBackendApplied": "Label-Index-Backend angewendet", + "embeddings.indexBackendFailed": "Label-Index-Backend konnte nicht geändert werden", + "embeddings.indexRebuild": "Indizes neu erstellen", + "embeddings.indexRebuilding": "Neuaufbau läuft…", + "embeddings.indexRebuilt": "Label-Indizes neu erstellt", + "embeddings.indexRebuildFailed": "Label-Indizes konnten nicht neu erstellt werden", + "embeddings.indexBenchmark": "Leistungsvergleich", + "embeddings.indexBenchmarking": "Benchmark läuft…", + "embeddings.indexBenchmarkPlaceholder": "Benchmark-Abfrage, z. B. fokussierte Coding-Sitzung", + "embeddings.indexBenchmarkFailed": "Benchmark fehlgeschlagen", + "embeddings.indexBenchmarkClose": "TurboQuant stimmt eng mit HNSW überein", + "embeddings.indexBenchmarkDiverged": "TurboQuant weicht von HNSW ab", + "embeddings.indexBenchmarkDelta": "Kosinusdistanz-Delta Ø {avg}, max. {max}", + "embeddings.indexBenchmarkNoResults": "Keine Ergebnisse", + "embeddings.indexUnavailable": "Index nicht verfügbar. Erstellen Sie zuerst die Indizes neu.", + "embeddings.rlxDevice": "RLX-Gerät", + "embeddings.rlxMaxSeq": "Max. Sequenz", + "embeddings.rlxHint": + "RLX lädt tokenizer.json und model.safetensors von Hugging Face herunter, dann werden Vektoren lokal gepoolt und normalisiert.", + "embeddings.rlxQuantizedUnsupported": + "Quantisierte FastEmbed-Modelle sind ORT-spezifisch. Wählen Sie ein nicht quantisiertes Safetensors-Modell, um RLX zu verwenden.", "embeddings.info": "Einbettungen werden für den Text und Kontext jedes Labels generiert. Beim ersten Start werden die Modellgewichte einmalig heruntergeladen und lokal gespeichert. Kleinere Modelle (≤384d) sind schnell; größere erzeugen reichhaltigere Repräsentationen.", "embeddings.sharedNote": @@ -304,6 +341,10 @@ const search: Record = { "search.nodeScreenshotsTip": "Screenshots in der Nähe", "search.maxTokens": "Token", + + "embeddings.indexMemory": "Speicherverbrauch auf der Festplatte", + "embeddings.indexMemoryRow": "{backend}: {total} ({text} Text · {context} Kontext · {eeg} EEG)", + "embeddings.indexMemoryTotal": "Gesamt: {total}", }; export default search; diff --git a/src/lib/i18n/de/settings.ts b/src/lib/i18n/de/settings.ts index 31a10a6b..456b96ae 100644 --- a/src/lib/i18n/de/settings.ts +++ b/src/lib/i18n/de/settings.ts @@ -833,6 +833,24 @@ const settings: Record = { "activity.productivePct": "produktiv %", "activity.totalReadingTime": "Lesezeit", "activity.avgScrollDepth": "Ø Scrolltiefe", + + "daemonActivity.title": "Hintergrundaktivität des Daemons", + "daemonActivity.intro": + "Wiederkehrende Aufgaben, die der Daemon im Hintergrund ausführt — was sie tun, wozu sie da sind und wie oft sie laufen. Deaktiviere ungenutzte Tracker, um CPU-Last zu sparen.", + "daemonActivity.loading": "Wird geladen …", + "daemonActivity.running": "aktiv", + "daemonActivity.idle": "inaktiv", + "daemonActivity.eventDriven": "ereignisbasiert", + "daemonActivity.whyPrefix": "Warum:", + "daemonActivity.costLow": "geringe Last", + "daemonActivity.costMedium": "mittlere Last", + "daemonActivity.costHigh": "hohe Last", + "daemonActivity.never": "noch nicht ausgeführt", + "daemonActivity.lastRanSecondsAgo": "vor {n} s", + "daemonActivity.lastRanMinutesAgo": "vor {n} min", + "daemonActivity.lastRanHoursAgo": "vor {n} h", + "daemonActivity.tickDuration": "Dauer: {n} ms", + "daemonActivity.tickCount": "{n}× ausgeführt", }; export default settings; diff --git a/src/lib/i18n/de/ui.ts b/src/lib/i18n/de/ui.ts index 3850e66c..fbda46b1 100644 --- a/src/lib/i18n/de/ui.ts +++ b/src/lib/i18n/de/ui.ts @@ -48,6 +48,8 @@ const ui: Record = { "tip.sampleEntropy": "Stichproben-Entropie - Unregelmäßigkeit des Signals. Höher = unvorhersagbarer.", "tip.pacThetaGamma": "Phasen-Amplituden-Kopplung zwischen Theta und Gamma. Verknüpft mit Gedächtniskodierung.", "tip.lateralityIndex": "Links-Rechts-Leistungsasymmetrie. Positiv = rechtsdominant.", + "tip.echt": + "Endpunkt-korrigierte Hilbert-Transformation — Alpha-Band-Rhythmizität (0–1). Hoch = starke, phasenstabile Alpha-Oszillation. [Schreglmann 2021]", "tip.hr": "Herzfrequenz aus PPG-Intervallen zwischen Herzschlägen.", "tip.rmssd": "Quadratischer Mittelwert aufeinanderfolgender Differenzen. Wichtige parasympathische HRV-Metrik.", "tip.sdnn": "Standardabweichung der Schlag-zu-Schlag-Intervalle. Spiegelt die gesamte HRV wider.", @@ -215,6 +217,12 @@ const ui: Record = { "Automatische Updateprüfung ist deaktiviert. Nutze den Button oben zur manuellen Prüfung.", "updates.autostart": "Bei Anmeldung starten", "updates.autostartDesc": "Startet automatisch, wenn du dich an deinem Computer anmeldest.", + "updates.autoUpdate": "Updates automatisch installieren", + "updates.autoUpdateDesc": + "Neue Versionen im Hintergrund herunterladen und beim nächsten Neustart installieren. Deaktivieren, um den Zeitpunkt selbst zu wählen.", + "updates.autoUpdateOffNotice": + "Automatische Installation ist aus — auf „Installieren“ klicken, um herunterzuladen und zu aktualisieren.", + "updates.installNow": "Installieren", "updates.autoCheckDesc": "Nach Updates prüfen, sobald die App startet, einmal pro Tag.", "updates.footer": "Updates werden automatisch heruntergeladen. Starten Sie neu, wenn Sie bereit sind.", diff --git a/src/lib/i18n/en/dashboard.ts b/src/lib/i18n/en/dashboard.ts index f5594af7..874cc05f 100644 --- a/src/lib/i18n/en/dashboard.ts +++ b/src/lib/i18n/en/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Sample Ent.", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "Laterality", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG Metrics", "dashboard.hr": "Heart Rate", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/en/history.ts b/src/lib/i18n/en/history.ts index e3c87f51..cea8c1bb 100644 --- a/src/lib/i18n/en/history.ts +++ b/src/lib/i18n/en/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "Mu Supp.", "sd.laterality": "Laterality", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth Act.", "sd.hjorthMob": "Hjorth Mob.", "sd.hjorthCmpl": "Hjorth Cmpl.", @@ -95,6 +96,8 @@ const history: Record = { "history.samples": "Samples", "history.device": "Device", "history.battery": "Battery", + "history.chunkCount": "{n} chunks", + "history.chunkCountTooltip": "Long recording split into {n} files for crash safety; spans {duration}", "history.snr": "Signal Quality", "history.label": "label", "history.labels": "labels", @@ -203,6 +206,7 @@ const history: Record = { "compare.sampleEntropy": "Sample Entropy", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "Laterality Index", + "compare.echt": "ECHT (alpha rhythmicity)", "compare.hr": "Heart Rate", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/en/llm.ts b/src/lib/i18n/en/llm.ts index 1efe0245..d19534bb 100644 --- a/src/lib/i18n/en/llm.ts +++ b/src/lib/i18n/en/llm.ts @@ -87,6 +87,11 @@ const llm: Record = { "model.idleReembedProcessing": "Processing {day} ({done}/{total})", "model.idleReembedWaiting": "Starts after {remaining}s idle", "model.idleReembedIdle": "Waiting for idle period", + "model.idleReembedMemoryThrottled": "Deferred — system memory at {pct}% (limit {limit}%)", + "model.maxResidentMemory": "Max system memory", + "model.maxResidentMemoryDesc": + "Skip background embedding when system memory exceeds this share of total. 100% disables the guard.", + "model.maxResidentMemoryDisabled": "off", "search.eegCoverage": "EEG Coverage", "search.eegCoverageLabel": "{embedded}/{total} ({pct}%)", @@ -95,6 +100,7 @@ const llm: Record = { "llm.section.models": "Language Models", "llm.section.mmproj": "Multimodal Projectors", "llm.section.inference": "Inference Settings", + "llm.section.mtp": "Multi-Token Prediction", "llm.enabled": "Enable LLM server", "llm.enabledDesc": "Run an OpenAI-compatible inference server on the same port as the WebSocket API. Requires the llm Cargo feature and a downloaded model.", @@ -292,6 +298,10 @@ const llm: Record = { "llm.inference.offloadKqvDesc": "Offload K/Q/V tensor operations to the GPU even when not all transformer layers are GPU-offloaded. Recommended for hybrid CPU+GPU setups.", + "llm.mtp.draftTokens": "Draft tokens", + "llm.mtp.draftTokensDesc": + "Number of tokens to speculatively draft per decode step. Higher values increase throughput but require more memory. Requires an MTP-enabled model.", + "chat.status.running": "Running", "chat.status.loading": "Loading model…", "chat.status.stopped": "Server stopped", diff --git a/src/lib/i18n/en/search.ts b/src/lib/i18n/en/search.ts index 761e6c80..433a91a0 100644 --- a/src/lib/i18n/en/search.ts +++ b/src/lib/i18n/en/search.ts @@ -9,6 +9,46 @@ const search: Record = { "embeddings.model": "Embedding Model", "embeddings.modelApplied": "Embedding model applied", "embeddings.modelFailed": "Failed to apply model", + "embeddings.backend": "Embedding Runtime", + "embeddings.backendDesc": + "Choose the execution backend for text embeddings. FastEmbed uses ORT; RLX runs the same embedding graphs through the local RLX runtime when this daemon was built with RLX support.", + "embeddings.backendFastembed": "FastEmbed / ORT", + "embeddings.backendFastembedDesc": "Default, compatible with every listed model.", + "embeddings.backendRlx": "RLX", + "embeddings.backendRlxDesc": "Experimental, faster path for safetensors BERT/Nomic models.", + "embeddings.indexBackend": "Label Search Index", + "embeddings.indexBackendDesc": + "Choose which local vector index powers label semantic search. Build both, benchmark with your own query, then pick the faster or higher-quality option for your data.", + "embeddings.indexBackend.hnsw": "HNSW", + "embeddings.indexBackend.hnswDesc": "Current default. Strong recall, larger in-memory graph.", + "embeddings.indexBackend.turboquant": "TurboQuant", + "embeddings.indexBackend.turboquantDesc": + "Compressed TurboVec index. Lower memory and disk use, good for large label sets.", + "embeddings.indexCurrent": "Current search backend: {backend}", + "embeddings.indexBackendApplied": "Label index backend applied", + "embeddings.indexBackendFailed": "Failed to change label index backend", + "embeddings.indexRebuild": "Rebuild indexes", + "embeddings.indexRebuilding": "Rebuilding…", + "embeddings.indexRebuilt": "Label indexes rebuilt", + "embeddings.indexRebuildFailed": "Failed to rebuild label indexes", + "embeddings.indexBenchmark": "Benchmark", + "embeddings.indexBenchmarking": "Benchmarking…", + "embeddings.indexBenchmarkPlaceholder": "Benchmark query, e.g. focused coding session", + "embeddings.indexBenchmarkFailed": "Benchmark failed", + "embeddings.indexBenchmarkClose": "TurboQuant matches HNSW closely", + "embeddings.indexBenchmarkDiverged": "TurboQuant differs from HNSW", + "embeddings.indexBenchmarkDelta": "cosine distance delta avg {avg}, max {max}", + "embeddings.indexBenchmarkNoResults": "No results", + "embeddings.indexUnavailable": "Index unavailable. Rebuild indexes first.", + "embeddings.indexMemory": "On-disk footprint", + "embeddings.indexMemoryRow": "{backend}: {total} ({text} text · {context} context · {eeg} eeg)", + "embeddings.indexMemoryTotal": "Total: {total}", + "embeddings.rlxDevice": "RLX Device", + "embeddings.rlxMaxSeq": "Max sequence", + "embeddings.rlxHint": + "RLX downloads tokenizer.json and model.safetensors from Hugging Face, then pools and normalizes vectors locally.", + "embeddings.rlxQuantizedUnsupported": + "Quantized FastEmbed models are ORT-specific. Choose a non-quantized safetensors model to use RLX.", "embeddings.info": "Embeddings are generated for each label's text and context. On first use the model weights are downloaded once and cached locally. Smaller models (≤384d) are fast; larger models produce richer representations.", "embeddings.sharedNote": diff --git a/src/lib/i18n/en/settings.ts b/src/lib/i18n/en/settings.ts index 4fc05e16..f243b077 100644 --- a/src/lib/i18n/en/settings.ts +++ b/src/lib/i18n/en/settings.ts @@ -807,6 +807,24 @@ const settings: Record = { "settings.hfEndpointDesc": "Optional mirror/base URL for model downloads (LLM + EXG + TTS). Default follows HF_ENDPOINT or https://huggingface.co.", "settings.hfEndpointCurrent": "Current", + + "daemonActivity.title": "Daemon Background Activity", + "daemonActivity.intro": + "Every recurring task the daemon runs in the background — what it does, why it exists, and how often it wakes up. Disable any tracker you don't use to free CPU.", + "daemonActivity.loading": "Loading…", + "daemonActivity.running": "running", + "daemonActivity.idle": "idle", + "daemonActivity.eventDriven": "event-driven", + "daemonActivity.whyPrefix": "Why:", + "daemonActivity.costLow": "low cost", + "daemonActivity.costMedium": "medium cost", + "daemonActivity.costHigh": "high cost", + "daemonActivity.never": "never run yet", + "daemonActivity.lastRanSecondsAgo": "last ran {n}s ago", + "daemonActivity.lastRanMinutesAgo": "last ran {n}m ago", + "daemonActivity.lastRanHoursAgo": "last ran {n}h ago", + "daemonActivity.tickDuration": "took {n} ms", + "daemonActivity.tickCount": "{n} ticks", }; export default settings; diff --git a/src/lib/i18n/en/ui.ts b/src/lib/i18n/en/ui.ts index b440bc22..81882522 100644 --- a/src/lib/i18n/en/ui.ts +++ b/src/lib/i18n/en/ui.ts @@ -40,6 +40,8 @@ const ui: Record = { "tip.sampleEntropy": "Sample Entropy — irregularity of the signal. Higher = less predictable.", "tip.pacThetaGamma": "Phase-Amplitude Coupling between theta phase and gamma amplitude. Linked to memory encoding.", "tip.lateralityIndex": "Left–right power asymmetry across all bands. Positive = right-dominant.", + "tip.echt": + "Endpoint-Corrected Hilbert Transform — alpha-band rhythmicity (0–1). High = strong, phase-stable alpha oscillation. [Schreglmann 2021]", "tip.hr": "Heart rate derived from PPG inter-beat intervals.", "tip.rmssd": "Root Mean Square of Successive Differences between heartbeats. Key parasympathetic HRV metric.", "tip.sdnn": "Standard deviation of beat-to-beat intervals. Reflects overall HRV.", @@ -119,6 +121,11 @@ const ui: Record = { "updates.intervalOffWarning": "Automatic update checks are disabled. Use the button above to check manually.", "updates.autostart": "Launch at Login", "updates.autostartDesc": "Start automatically when you log in to your computer.", + "updates.autoUpdate": "Install updates automatically", + "updates.autoUpdateDesc": + "Download new versions in the background and install them on the next restart. Turn off to choose when to install.", + "updates.autoUpdateOffNotice": "Automatic install is off — click Install to download and update.", + "updates.installNow": "Install", "updates.receivePrereleases": "Receive pre-releases", "updates.receivePrereleasesDesc": "Opt into release candidates ahead of stable releases. Both manual and background update checks honor this setting live.", diff --git a/src/lib/i18n/es/dashboard.ts b/src/lib/i18n/es/dashboard.ts index d50d2622..286951aa 100644 --- a/src/lib/i18n/es/dashboard.ts +++ b/src/lib/i18n/es/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Muestra de Ent.", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "Lateralidad", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "Métricas de PPG", "dashboard.hr": "Frecuencia cardíaca", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/es/history.ts b/src/lib/i18n/es/history.ts index f2b8ea40..cccd9935 100644 --- a/src/lib/i18n/es/history.ts +++ b/src/lib/i18n/es/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "Sup. mu", "sd.laterality": "Lateralidad", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Ley Hjorth.", "sd.hjorthMob": "Mafia Hjorth.", "sd.hjorthCmpl": "Comp. Hjorth", @@ -95,6 +96,9 @@ const history: Record = { "history.samples": "Muestras", "history.device": "Dispositivo", "history.battery": "Batería", + "history.chunkCount": "{n} fragmentos", + "history.chunkCountTooltip": + "Grabación larga dividida en {n} archivos por seguridad ante fallos; duración {duration}", "history.snr": "Calidad de la señal", "history.label": "etiqueta", "history.labels": "etiquetas", @@ -204,6 +208,7 @@ const history: Record = { "compare.sampleEntropy": "Entropía de muestra", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "Índice de lateralidad", + "compare.echt": "ECHT (ritmicidad alfa)", "compare.hr": "Frecuencia cardíaca", "compare.rmssd": "RMSSD (VFC)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/es/llm.ts b/src/lib/i18n/es/llm.ts index 42e0dc9c..ffc6ed67 100644 --- a/src/lib/i18n/es/llm.ts +++ b/src/lib/i18n/es/llm.ts @@ -83,6 +83,7 @@ const llm: Record = { "llm.section.models": "Modelos de lenguaje", "llm.section.mmproj": "Proyectores multimodales", "llm.section.inference": "Configuración de inferencia", + "llm.section.mtp": "Predicción multi-token", "llm.enabled": "Habilitar servidor LLM", "llm.enabledDesc": "Ejecute un servidor de inferencia compatible con OpenAI en el mismo puerto que la API WebSocket. Requiere la función llm Cargo y un modelo descargado.", @@ -293,6 +294,10 @@ const llm: Record = { "llm.inference.offloadKqvDesc": "Descargar operaciones de tensores K/Q/V a la GPU incluso cuando no todas las capas están en GPU.", + "llm.mtp.draftTokens": "Tokens de borrador", + "llm.mtp.draftTokensDesc": + "Número de tokens generados especulativamente por paso de decodificación. Valores más altos aumentan el rendimiento pero requieren más memoria. Requiere un modelo con MTP habilitado.", + "chat.status.running": "Correr", "chat.status.loading": "Cargando modelo…", "chat.status.stopped": "Servidor detenido", @@ -496,6 +501,12 @@ const llm: Record = { "model.idleReembedIdle": "Esperando período de inactividad", "search.eegCoverage": "Cobertura EEG", "search.eegCoverageLabel": "{embedded} de {total} ({pct} %)", + + "model.idleReembedMemoryThrottled": "Aplazado: memoria del sistema al {pct}% (límite {limit}%)", + "model.maxResidentMemory": "Memoria máxima del sistema", + "model.maxResidentMemoryDesc": + "Omitir la incrustación en segundo plano cuando la memoria del sistema supere este porcentaje. 100% desactiva el límite.", + "model.maxResidentMemoryDisabled": "desact.", }; export default llm; diff --git a/src/lib/i18n/es/search.ts b/src/lib/i18n/es/search.ts index 97867e8e..c45eda30 100644 --- a/src/lib/i18n/es/search.ts +++ b/src/lib/i18n/es/search.ts @@ -9,6 +9,43 @@ const search: Record = { "embeddings.model": "Modelo de incrustación", "embeddings.modelApplied": "Modelo de embeddings aplicado", "embeddings.modelFailed": "No se pudo aplicar el modelo", + "embeddings.backend": "Runtime de embeddings", + "embeddings.backendDesc": + "Elige el backend de ejecución para embeddings de texto. FastEmbed usa ORT; RLX ejecuta los mismos grafos de embeddings mediante el runtime local de RLX cuando el daemon se compiló con soporte RLX.", + "embeddings.backendFastembed": "FastEmbed / ORT (predeterminado)", + "embeddings.backendFastembedDesc": "Predeterminado, compatible con todos los modelos de la lista.", + "embeddings.backendRlx": "RLX", + "embeddings.backendRlxDesc": "Ruta experimental más rápida para modelos BERT/Nomic en safetensors.", + "embeddings.indexBackend": "Índice de búsqueda de etiquetas", + "embeddings.indexBackendDesc": + "Elige qué índice vectorial local impulsa la búsqueda semántica de etiquetas. Crea ambos, compara con tu propia consulta y luego elige la opción más rápida o de mayor calidad para tus datos.", + "embeddings.indexBackend.hnsw": "HNSW", + "embeddings.indexBackend.hnswDesc": "Valor predeterminado actual. Gran recall, grafo más grande en memoria.", + "embeddings.indexBackend.turboquant": "Índice TurboQuant", + "embeddings.indexBackend.turboquantDesc": + "Índice TurboVec comprimido. Menor uso de memoria y disco, bueno para conjuntos grandes de etiquetas.", + "embeddings.indexCurrent": "Backend de búsqueda actual: {backend}", + "embeddings.indexBackendApplied": "Backend del índice de etiquetas aplicado", + "embeddings.indexBackendFailed": "No se pudo cambiar el backend del índice de etiquetas", + "embeddings.indexRebuild": "Reconstruir índices", + "embeddings.indexRebuilding": "Reconstruyendo…", + "embeddings.indexRebuilt": "Índices de etiquetas reconstruidos", + "embeddings.indexRebuildFailed": "No se pudieron reconstruir los índices de etiquetas", + "embeddings.indexBenchmark": "Prueba comparativa", + "embeddings.indexBenchmarking": "Ejecutando benchmark…", + "embeddings.indexBenchmarkPlaceholder": "Consulta de benchmark, p. ej. sesión de programación enfocada", + "embeddings.indexBenchmarkFailed": "Benchmark fallido", + "embeddings.indexBenchmarkClose": "TurboQuant coincide estrechamente con HNSW", + "embeddings.indexBenchmarkDiverged": "TurboQuant difiere de HNSW", + "embeddings.indexBenchmarkDelta": "delta de distancia coseno prom. {avg}, máx. {max}", + "embeddings.indexBenchmarkNoResults": "Sin resultados", + "embeddings.indexUnavailable": "Índice no disponible. Reconstruye los índices primero.", + "embeddings.rlxDevice": "Dispositivo RLX", + "embeddings.rlxMaxSeq": "Secuencia máxima", + "embeddings.rlxHint": + "RLX descarga tokenizer.json y model.safetensors de Hugging Face, luego agrupa y normaliza los vectores localmente.", + "embeddings.rlxQuantizedUnsupported": + "Los modelos cuantizados de FastEmbed son específicos de ORT. Elige un modelo safetensors no cuantizado para usar RLX.", "embeddings.info": "Las incrustaciones se generan para el texto y el contexto de cada etiqueta. En el primer uso, los pesos del modelo se descargan una vez y se almacenan en caché localmente. Los modelos más pequeños (≤384d) son rápidos; Los modelos más grandes producen representaciones más ricas.", "embeddings.sharedNote": @@ -304,6 +341,10 @@ const search: Record = { "search.nodeScreenshotsTip": "Capturas cerca de coincidencias", "search.maxTokens": "Fichas", + + "embeddings.indexMemory": "Espacio en disco", + "embeddings.indexMemoryRow": "{backend}: {total} ({text} texto · {context} contexto · {eeg} EEG)", + "embeddings.indexMemoryTotal": "En total: {total}", }; export default search; diff --git a/src/lib/i18n/es/settings.ts b/src/lib/i18n/es/settings.ts index 2fd30059..1175c619 100644 --- a/src/lib/i18n/es/settings.ts +++ b/src/lib/i18n/es/settings.ts @@ -839,6 +839,24 @@ const settings: Record = { "activity.productivePct": "% productivo", "activity.totalReadingTime": "tiempo de lectura", "activity.avgScrollDepth": "profundidad media", + + "daemonActivity.title": "Actividad en segundo plano del daemon", + "daemonActivity.intro": + "Tareas recurrentes que el daemon ejecuta en segundo plano: qué hacen, para qué sirven y con qué frecuencia se ejecutan. Desactiva los rastreadores que no uses para reducir la carga de CPU.", + "daemonActivity.loading": "Cargando…", + "daemonActivity.running": "activo", + "daemonActivity.idle": "inactivo", + "daemonActivity.eventDriven": "por eventos", + "daemonActivity.whyPrefix": "Por qué:", + "daemonActivity.costLow": "carga baja", + "daemonActivity.costMedium": "carga media", + "daemonActivity.costHigh": "carga alta", + "daemonActivity.never": "aún no se ha ejecutado", + "daemonActivity.lastRanSecondsAgo": "hace {n} s", + "daemonActivity.lastRanMinutesAgo": "hace {n} min", + "daemonActivity.lastRanHoursAgo": "hace {n} h", + "daemonActivity.tickDuration": "duración: {n} ms", + "daemonActivity.tickCount": "ciclos: {n}", }; export default settings; diff --git a/src/lib/i18n/es/ui.ts b/src/lib/i18n/es/ui.ts index 276347af..ee7245da 100644 --- a/src/lib/i18n/es/ui.ts +++ b/src/lib/i18n/es/ui.ts @@ -50,6 +50,8 @@ const ui: Record = { "tip.pacThetaGamma": "Acoplamiento fase-amplitud entre fase theta y amplitud gamma. Vinculado a la codificación de la memoria.", "tip.lateralityIndex": "Asimetría de poder izquierda-derecha en todas las bandas. Positivo = derecha dominante.", + "tip.echt": + "Transformada de Hilbert con corrección de extremos — ritmicidad de banda alfa (0–1). Alto = oscilación alfa intensa y estable en fase. [Schreglmann 2021]", "tip.hr": "Frecuencia cardíaca derivada de los intervalos entre latidos PPG.", "tip.rmssd": "Media cuadrática de diferencias sucesivas entre latidos del corazón. Métrica clave de VFC parasimpática.", @@ -135,6 +137,12 @@ const ui: Record = { "Las comprobaciones de actualizaciones automáticas están deshabilitadas. Utilice el botón de arriba para comprobarlo manualmente.", "updates.autostart": "Iniciar sesión", "updates.autostartDesc": "Se inicia automáticamente cuando inicia sesión en su computadora.", + "updates.autoUpdate": "Instalar actualizaciones automáticamente", + "updates.autoUpdateDesc": + "Descarga las nuevas versiones en segundo plano e instálalas al reiniciar. Desactiva esta opción para elegir cuándo instalar.", + "updates.autoUpdateOffNotice": + "La instalación automática está desactivada — haz clic en Instalar para descargar y actualizar.", + "updates.installNow": "Instalar", "updates.footer": "Las actualizaciones se descargan automáticamente. Reinicie cuando esté listo para presentar la solicitud.", diff --git a/src/lib/i18n/fr/dashboard.ts b/src/lib/i18n/fr/dashboard.ts index 0ae72e44..28d07043 100644 --- a/src/lib/i18n/fr/dashboard.ts +++ b/src/lib/i18n/fr/dashboard.ts @@ -54,6 +54,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Ent. échantillon", "dashboard.pacThetaGamma": "CAP (θ-γ)", "dashboard.lateralityIndex": "Latéralité", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "Métriques PPG", "dashboard.hr": "Fréq. cardiaque", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/fr/history.ts b/src/lib/i18n/fr/history.ts index b7f78438..25c0c6d2 100644 --- a/src/lib/i18n/fr/history.ts +++ b/src/lib/i18n/fr/history.ts @@ -66,6 +66,7 @@ const history: Record = { "compare.sampleEntropy": "Entropie d'échantillon", "compare.pacThetaGamma": "CAP (θ-γ)", "compare.lateralityIndex": "Indice de latéralité", + "compare.echt": "ECHT (rythmicité alpha)", "compare.hr": "Fréquence cardiaque", "compare.rmssd": "RMSSD (VFC)", "compare.sdnn": "SDNN (VFC)", @@ -144,6 +145,7 @@ const history: Record = { "sd.muSupp": "Suppr. Mu", "sd.laterality": "Latéralité", "sd.pac": "CPA θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth Act.", "sd.hjorthMob": "Hjorth Mob.", "sd.hjorthCmpl": "Hjorth Compl.", @@ -196,6 +198,8 @@ const history: Record = { "history.samples": "Échantillons", "history.device": "Appareil", "history.battery": "Batterie", + "history.chunkCount": "{n} segments", + "history.chunkCountTooltip": "Enregistrement long divisé en {n} fichiers par sécurité ; durée {duration}", "history.snr": "Qualité du signal", "history.label": "étiquette", "history.labels": "étiquettes", diff --git a/src/lib/i18n/fr/llm.ts b/src/lib/i18n/fr/llm.ts index a33643ef..62d16d0d 100644 --- a/src/lib/i18n/fr/llm.ts +++ b/src/lib/i18n/fr/llm.ts @@ -83,6 +83,7 @@ const llm: Record = { "llm.section.models": "Modèles de langage", "llm.section.mmproj": "Projecteurs multimodaux", "llm.section.inference": "Paramètres d'inférence", + "llm.section.mtp": "Prédiction multi-token", "llm.enabled": "Activer le serveur LLM", "llm.enabledDesc": "Exécutez un serveur d'inférence compatible OpenAI sur le même port que l'API WebSocket. Nécessite la fonctionnalité llm Cargo et un modèle téléchargé.", @@ -282,6 +283,10 @@ const llm: Record = { "llm.inference.offloadKqvDesc": "Décharger les opérations tensorielles K/Q/V sur le GPU même si toutes les couches ne sont pas sur GPU.", + "llm.mtp.draftTokens": "Tokens brouillon", + "llm.mtp.draftTokensDesc": + "Nombre de tokens générés spéculativement par étape de décodage. Des valeurs plus élevées augmentent le débit mais nécessitent plus de mémoire. Nécessite un modèle compatible MTP.", + "llm.hfSearch.title": "Rechercher des modèles HuggingFace", "llm.hfSearch.placeholder": "Rechercher des modèles GGUF sur HuggingFace…", "llm.hfSearch.searchBtn": "Rechercher", @@ -487,6 +492,12 @@ const llm: Record = { "model.idleReembedIdle": "En attente de période d'inactivité", "search.eegCoverage": "Couverture EEG", "search.eegCoverageLabel": "{embedded} sur {total} ({pct} %)", + + "model.idleReembedMemoryThrottled": "Reporté — mémoire système à {pct}% (limite {limit}%)", + "model.maxResidentMemory": "Mémoire système maximale", + "model.maxResidentMemoryDesc": + "Suspendre l'embedding en arrière-plan lorsque la mémoire système dépasse ce pourcentage. 100% désactive la protection.", + "model.maxResidentMemoryDisabled": "désactivé", }; export default llm; diff --git a/src/lib/i18n/fr/search.ts b/src/lib/i18n/fr/search.ts index f20d8669..596bdf6c 100644 --- a/src/lib/i18n/fr/search.ts +++ b/src/lib/i18n/fr/search.ts @@ -9,6 +9,43 @@ const search: Record = { "embeddings.model": "Modèle d'embedding", "embeddings.modelApplied": "Modèle d'embeddings appliqué", "embeddings.modelFailed": "Échec de l'application du modèle", + "embeddings.backend": "Runtime d'embeddings", + "embeddings.backendDesc": + "Choisissez le backend d'exécution pour les embeddings texte. FastEmbed utilise ORT ; RLX exécute les mêmes graphes d'embedding via le runtime RLX local lorsque le daemon a été compilé avec le support RLX.", + "embeddings.backendFastembed": "FastEmbed / ORT (par défaut)", + "embeddings.backendFastembedDesc": "Par défaut, compatible avec tous les modèles listés.", + "embeddings.backendRlx": "RLX", + "embeddings.backendRlxDesc": "Chemin expérimental plus rapide pour les modèles BERT/Nomic safetensors.", + "embeddings.indexBackend": "Index de recherche des labels", + "embeddings.indexBackendDesc": + "Choisissez l'index vectoriel local qui alimente la recherche sémantique de labels. Construisez les deux, comparez avec votre propre requête, puis choisissez l'option la plus rapide ou la plus qualitative pour vos données.", + "embeddings.indexBackend.hnsw": "HNSW", + "embeddings.indexBackend.hnswDesc": "Valeur par défaut actuelle. Fort rappel, graphe plus volumineux en mémoire.", + "embeddings.indexBackend.turboquant": "Index TurboQuant", + "embeddings.indexBackend.turboquantDesc": + "Index TurboVec compressé. Utilise moins de mémoire et d'espace disque, adapté aux grands ensembles de labels.", + "embeddings.indexCurrent": "Backend de recherche actuel : {backend}", + "embeddings.indexBackendApplied": "Backend d'index des labels appliqué", + "embeddings.indexBackendFailed": "Échec du changement de backend d'index des labels", + "embeddings.indexRebuild": "Reconstruire les index", + "embeddings.indexRebuilding": "Reconstruction…", + "embeddings.indexRebuilt": "Index des labels reconstruits", + "embeddings.indexRebuildFailed": "Échec de la reconstruction des index des labels", + "embeddings.indexBenchmark": "Comparatif", + "embeddings.indexBenchmarking": "Benchmark en cours…", + "embeddings.indexBenchmarkPlaceholder": "Requête de benchmark, p. ex. session de codage concentrée", + "embeddings.indexBenchmarkFailed": "Échec du benchmark", + "embeddings.indexBenchmarkClose": "TurboQuant correspond étroitement à HNSW", + "embeddings.indexBenchmarkDiverged": "TurboQuant diffère de HNSW", + "embeddings.indexBenchmarkDelta": "delta de distance cosinus moy. {avg}, max {max}", + "embeddings.indexBenchmarkNoResults": "Aucun résultat", + "embeddings.indexUnavailable": "Index indisponible. Reconstruisez d'abord les index.", + "embeddings.rlxDevice": "Périphérique RLX", + "embeddings.rlxMaxSeq": "Séquence max.", + "embeddings.rlxHint": + "RLX télécharge tokenizer.json et model.safetensors depuis Hugging Face, puis effectue le pooling et la normalisation localement.", + "embeddings.rlxQuantizedUnsupported": + "Les modèles FastEmbed quantifiés sont spécifiques à ORT. Choisissez un modèle safetensors non quantifié pour utiliser RLX.", "embeddings.info": "Les embeddings sont générés pour le texte et le contexte de chaque étiquette. Lors de la première utilisation, les poids du modèle sont téléchargés une fois et mis en cache localement. Les modèles plus petits (≤384d) sont rapides ; les plus grands produisent des représentations plus riches.", "embeddings.sharedNote": @@ -304,6 +341,10 @@ const search: Record = { "search.nodeScreenshotsTip": "Captures près des correspondances", "search.maxTokens": "Jetons", + + "embeddings.indexMemory": "Empreinte sur disque", + "embeddings.indexMemoryRow": "{backend} : {total} ({text} texte · {context} contexte · {eeg} EEG)", + "embeddings.indexMemoryTotal": "Total : {total}", }; export default search; diff --git a/src/lib/i18n/fr/settings.ts b/src/lib/i18n/fr/settings.ts index 4638d55a..3f35e8f3 100644 --- a/src/lib/i18n/fr/settings.ts +++ b/src/lib/i18n/fr/settings.ts @@ -841,6 +841,24 @@ const settings: Record = { "activity.productivePct": "productif %", "activity.totalReadingTime": "temps de lecture", "activity.avgScrollDepth": "profondeur moy.", + + "daemonActivity.title": "Activité en arrière-plan du daemon", + "daemonActivity.intro": + "Tâches récurrentes exécutées par le daemon en arrière-plan : ce qu'elles font, à quoi elles servent et à quelle fréquence elles s'exécutent. Désactive les trackers inutilisés pour réduire la charge CPU.", + "daemonActivity.loading": "Chargement…", + "daemonActivity.running": "actif", + "daemonActivity.idle": "inactif", + "daemonActivity.eventDriven": "événementiel", + "daemonActivity.whyPrefix": "Pourquoi :", + "daemonActivity.costLow": "charge faible", + "daemonActivity.costMedium": "charge moyenne", + "daemonActivity.costHigh": "charge élevée", + "daemonActivity.never": "pas encore exécuté", + "daemonActivity.lastRanSecondsAgo": "il y a {n} s", + "daemonActivity.lastRanMinutesAgo": "il y a {n} min", + "daemonActivity.lastRanHoursAgo": "il y a {n} h", + "daemonActivity.tickDuration": "durée : {n} ms", + "daemonActivity.tickCount": "cycles : {n}", }; export default settings; diff --git a/src/lib/i18n/fr/ui.ts b/src/lib/i18n/fr/ui.ts index cf836148..4b583545 100644 --- a/src/lib/i18n/fr/ui.ts +++ b/src/lib/i18n/fr/ui.ts @@ -49,6 +49,8 @@ const ui: Record = { "tip.sampleEntropy": "Entropie d'échantillon - irrégularité du signal. Plus élevé = moins prévisible.", "tip.pacThetaGamma": "Couplage phase-amplitude thêta-gamma. Lié à l'encodage mnésique.", "tip.lateralityIndex": "Asymétrie gauche-droite de puissance. Positif = dominance droite.", + "tip.echt": + "Transformée de Hilbert à correction d'extrémité — rythmicité de la bande alpha (0–1). Élevé = oscillation alpha forte et phase stable. [Schreglmann 2021]", "tip.hr": "Fréquence cardiaque dérivée des intervalles inter-battements PPG.", "tip.rmssd": "Racine carrée des différences successives. Métrique parasympathique clé de la VFC.", "tip.sdnn": "Écart type des intervalles battement par battement. Reflète la VFC globale.", @@ -216,6 +218,12 @@ const ui: Record = { "Les vérifications automatiques sont désactivées. Utilisez le bouton ci-dessus pour vérifier manuellement.", "updates.autostart": "Lancer à la connexion", "updates.autostartDesc": "Démarre automatiquement quand vous ouvrez une session.", + "updates.autoUpdate": "Installer les mises à jour automatiquement", + "updates.autoUpdateDesc": + "Télécharger les nouvelles versions en arrière-plan et les installer au prochain redémarrage. Désactivez pour choisir quand installer.", + "updates.autoUpdateOffNotice": + "L'installation automatique est désactivée — cliquez sur Installer pour télécharger et mettre à jour.", + "updates.installNow": "Installer", "updates.autoCheckDesc": "Vérifier les mises à jour une fois par jour au démarrage de l'application.", "updates.footer": "Les mises à jour sont téléchargées automatiquement. Redémarrez quand vous êtes prêt.", diff --git a/src/lib/i18n/he/dashboard.ts b/src/lib/i18n/he/dashboard.ts index e529660b..97b05980 100644 --- a/src/lib/i18n/he/dashboard.ts +++ b/src/lib/i18n/he/dashboard.ts @@ -28,6 +28,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "אנטר. דגימה", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "לטרליות", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "מדדי PPG", "dashboard.hr": "דופק", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/he/history.ts b/src/lib/i18n/he/history.ts index 6c28898f..17d87e87 100644 --- a/src/lib/i18n/he/history.ts +++ b/src/lib/i18n/he/history.ts @@ -64,6 +64,7 @@ const history: Record = { "compare.sampleEntropy": "אנטרופיית דגימה", "compare.pacThetaGamma": "צימוד פאזה-אמפליטודה (θ–γ)", "compare.lateralityIndex": "מדד לטרליות", + "compare.echt": "ECHT (קצביות אלפא)", "compare.hr": "דופק", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", @@ -142,6 +143,7 @@ const history: Record = { "sd.muSupp": "דיכוי Mu", "sd.laterality": "לטרליות", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth פעילות", "sd.hjorthMob": "Hjorth ניידות", "sd.hjorthCmpl": "Hjorth מורכבות", @@ -225,6 +227,8 @@ const history: Record = { "history.samples": "דגימות", "history.device": "מכשיר", "history.battery": "סוללה", + "history.chunkCount": "{n} מקטעים", + "history.chunkCountTooltip": "הקלטה ארוכה פוצלה ל-{n} קבצים להגנה מקריסה; משך {duration}", "history.snr": "איכות אות", "history.label": "תווית", "history.labels": "תוויות", diff --git a/src/lib/i18n/he/llm.ts b/src/lib/i18n/he/llm.ts index 5f976ecf..53540a81 100644 --- a/src/lib/i18n/he/llm.ts +++ b/src/lib/i18n/he/llm.ts @@ -43,6 +43,10 @@ const llm: Record = { "llm.inference.offloadKqv": "העברת KQV ל-GPU", "llm.inference.offloadKqvDesc": "העברת פעולות טנזור K/Q/V ל-GPU גם כאשר לא כל השכבות מועברות.", + "llm.mtp.draftTokens": "אסימוני טיוטה", + "llm.mtp.draftTokensDesc": + "מספר האסימונים שנוצרים בצורה ספקולטיבית בכל שלב פענוח. ערכים גבוהים יותר מגבירים את התפוקה אך דורשים יותר זיכרון. מחייב מודל עם תמיכה ב-MTP.", + "llm.hfSearch.title": "חיפוש מודלים ב-HuggingFace", "llm.hfSearch.placeholder": "חיפוש מודלי GGUF ב-HuggingFace…", "llm.hfSearch.searchBtn": "חיפוש", @@ -138,6 +142,7 @@ const llm: Record = { "llm.section.models": "מודלי שפה", "llm.section.mmproj": "מקרנים מולטימודליים", "llm.section.inference": "הגדרות אינפרנס", + "llm.section.mtp": "חיזוי רב-אסימון", "llm.enabled": "הפעל שרת LLM", "llm.enabledDesc": "הפעל שרת אינפרנס תואם OpenAI על אותו פורט של WebSocket API. דורש את פיצ׳ר llm ב-Cargo ומודל שהורד.", @@ -467,6 +472,11 @@ const llm: Record = { "model.idleReembedIdle": "ממתין לתקופת חוסר פעילות", "search.eegCoverage": "כיסוי EEG", "search.eegCoverageLabel": "{embedded} מתוך {total} ({pct}%)", + + "model.idleReembedMemoryThrottled": "נדחה — שימוש בזיכרון המערכת ב-{pct}% (מגבלה {limit}%)", + "model.maxResidentMemory": "זיכרון מערכת מרבי", + "model.maxResidentMemoryDesc": "דלג על הטמעה ברקע כאשר זיכרון המערכת חורג מאחוז זה. 100% מבטל את ההגנה.", + "model.maxResidentMemoryDisabled": "כבוי", }; export default llm; diff --git a/src/lib/i18n/he/search.ts b/src/lib/i18n/he/search.ts index dd6d4cbe..9768f038 100644 --- a/src/lib/i18n/he/search.ts +++ b/src/lib/i18n/he/search.ts @@ -9,6 +9,43 @@ const search: Record = { "embeddings.model": "מודל הטמעה", "embeddings.modelApplied": "מודל הטמעה הוחל", "embeddings.modelFailed": "החלת המודל נכשלה", + "embeddings.backend": "זמן ריצה להטמעות", + "embeddings.backendDesc": + "בחר את backend הביצוע להטמעות טקסט. FastEmbed משתמש ב-ORT; ‏RLX מריץ את אותם גרפי הטמעה דרך זמן הריצה המקומי של RLX כאשר הדמון נבנה עם תמיכת RLX.", + "embeddings.backendFastembed": "FastEmbed / ORT (ברירת מחדל)", + "embeddings.backendFastembedDesc": "ברירת המחדל, תואם לכל מודל ברשימה.", + "embeddings.backendRlx": "RLX", + "embeddings.backendRlxDesc": "נתיב ניסיוני ומהיר יותר למודלי BERT/Nomic מסוג safetensors.", + "embeddings.indexBackend": "אינדקס חיפוש תוויות", + "embeddings.indexBackendDesc": + "בחר איזה אינדקס וקטורי מקומי מפעיל את החיפוש הסמנטי בתוויות. בנה את שניהם, הרץ benchmark עם שאילתה משלך, ואז בחר את האפשרות המהירה או האיכותית יותר עבור הנתונים שלך.", + "embeddings.indexBackend.hnsw": "HNSW", + "embeddings.indexBackend.hnswDesc": "ברירת המחדל הנוכחית. Recall חזק, גרף גדול יותר בזיכרון.", + "embeddings.indexBackend.turboquant": "אינדקס TurboQuant", + "embeddings.indexBackend.turboquantDesc": + "אינדקס TurboVec דחוס. שימוש נמוך יותר בזיכרון ובדיסק, טוב לאוספי תוויות גדולים.", + "embeddings.indexCurrent": "backend החיפוש הנוכחי: {backend}", + "embeddings.indexBackendApplied": "backend אינדקס התוויות הוחל", + "embeddings.indexBackendFailed": "שינוי backend אינדקס התוויות נכשל", + "embeddings.indexRebuild": "בנה אינדקסים מחדש", + "embeddings.indexRebuilding": "בונה מחדש…", + "embeddings.indexRebuilt": "אינדקסי התוויות נבנו מחדש", + "embeddings.indexRebuildFailed": "בניית אינדקסי התוויות מחדש נכשלה", + "embeddings.indexBenchmark": "בדיקת ביצועים", + "embeddings.indexBenchmarking": "מריץ benchmark…", + "embeddings.indexBenchmarkPlaceholder": "שאילתת benchmark, למשל סשן קידוד ממוקד", + "embeddings.indexBenchmarkFailed": "ה-benchmark נכשל", + "embeddings.indexBenchmarkClose": "TurboQuant תואם ל-HNSW באופן הדוק", + "embeddings.indexBenchmarkDiverged": "TurboQuant שונה מ-HNSW", + "embeddings.indexBenchmarkDelta": "דלתא מרחק קוסינוס ממוצע {avg}, מקסימום {max}", + "embeddings.indexBenchmarkNoResults": "אין תוצאות", + "embeddings.indexUnavailable": "האינדקס אינו זמין. בנה תחילה את האינדקסים מחדש.", + "embeddings.rlxDevice": "התקן RLX", + "embeddings.rlxMaxSeq": "רצף מרבי", + "embeddings.rlxHint": + "RLX מוריד tokenizer.json ו-model.safetensors מ-Hugging Face, ואז מבצע pooling ונרמול וקטורים מקומית.", + "embeddings.rlxQuantizedUnsupported": + "מודלי FastEmbed מכומתים הם ייעודיים ל-ORT. בחר מודל safetensors לא מכומת כדי להשתמש ב-RLX.", "embeddings.info": "הטמעות נוצרות עבור הטקסט וההקשר של כל תווית. בשימוש הראשון משקלי המודל מורדים פעם אחת ומאוחסנים מקומית. מודלים קטנים (≤384d) מהירים; גדולים יותר מייצרים ייצוגים עשירים יותר.", "embeddings.sharedNote": @@ -292,6 +329,10 @@ const search: Record = { "search.nodeScreenshotsTip": "צילומי מסך ליד התאמות", "search.maxTokens": "אסימונים", + + "embeddings.indexMemory": "נפח אחסון בדיסק", + "embeddings.indexMemoryRow": "{backend}: {total} ({text} טקסט · {context} הקשר · {eeg} EEG)", + "embeddings.indexMemoryTotal": "סך הכול: {total}", }; export default search; diff --git a/src/lib/i18n/he/settings.ts b/src/lib/i18n/he/settings.ts index 0be066a9..49240fa8 100644 --- a/src/lib/i18n/he/settings.ts +++ b/src/lib/i18n/he/settings.ts @@ -790,6 +790,24 @@ const settings: Record = { "activity.productivePct": "% פרודוקטיבי", "activity.totalReadingTime": "זמן קריאה", "activity.avgScrollDepth": "עומק גלילה ממוצע", + + "daemonActivity.title": "פעילות רקע של ה-Daemon", + "daemonActivity.intro": + "משימות חוזרות שה-Daemon מריץ ברקע — מה הן עושות, לשם מה הן קיימות וכל כמה זמן הן רצות. כבה מעקבים שאינך משתמש בהם כדי להפחית עומס מעבד.", + "daemonActivity.loading": "בטעינה…", + "daemonActivity.running": "פעיל", + "daemonActivity.idle": "לא פעיל", + "daemonActivity.eventDriven": "מבוסס-אירועים", + "daemonActivity.whyPrefix": "למה:", + "daemonActivity.costLow": "עומס נמוך", + "daemonActivity.costMedium": "עומס בינוני", + "daemonActivity.costHigh": "עומס גבוה", + "daemonActivity.never": "עדיין לא רץ", + "daemonActivity.lastRanSecondsAgo": "לפני {n} שנ׳", + "daemonActivity.lastRanMinutesAgo": "לפני {n} דק׳", + "daemonActivity.lastRanHoursAgo": "לפני {n} שע׳", + "daemonActivity.tickDuration": "משך: {n} ms", + "daemonActivity.tickCount": "הפעלות: {n}", }; export default settings; diff --git a/src/lib/i18n/he/ui.ts b/src/lib/i18n/he/ui.ts index d7cf4789..c06f614a 100644 --- a/src/lib/i18n/he/ui.ts +++ b/src/lib/i18n/he/ui.ts @@ -58,6 +58,8 @@ const ui: Record = { "tip.sampleEntropy": "אנטרופיית מדגם — אי-סדירות האות. גבוה = פחות צפוי.", "tip.pacThetaGamma": "צימוד פאזה-אמפליטודה תטא–גמא. קשור לקידוד זיכרון.", "tip.lateralityIndex": "אסימטריית הספק שמאל-ימין. חיובי = דומיננטיות ימנית.", + "tip.echt": + "טרנספורם הילברט מתוקן-קצוות — קצביות פס אלפא (0–1). גבוה = תנודת אלפא חזקה ויציבה בפאזה. [Schreglmann 2021]", "tip.hr": "קצב לב מרווחי PPG בין פעימות.", "tip.rmssd": "שורש ממוצע ריבועי של הפרשים עוקבים. מדד פאראסימפתטי מרכזי של HRV.", "tip.sdnn": "סטיית תקן של מרווחי פעימה-לפעימה. משקף HRV כולל.", @@ -220,6 +222,10 @@ const ui: Record = { "updates.intervalOffWarning": "בדיקות אוטומטיות מושבתות. השתמש בכפתור למעלה לבדיקה ידנית.", "updates.autostart": "הפעלה בכניסה למערכת", "updates.autostartDesc": "מתחיל אוטומטית כשנכנסים למחשב.", + "updates.autoUpdate": "התקן עדכונים אוטומטית", + "updates.autoUpdateDesc": "הורד גרסאות חדשות ברקע והתקן בהפעלה הבאה. כבה כדי לבחור מתי להתקין.", + "updates.autoUpdateOffNotice": "התקנה אוטומטית כבויה — לחץ על התקן כדי להוריד ולעדכן.", + "updates.installNow": "התקן", "updates.autoCheckDesc": "בדוק עדכונים פעם ביום כאשר האפליקציה מתחילה.", "updates.footer": "עדכונים מורדים אוטומטית. הפעל מחדש כשנוח לך.", diff --git a/src/lib/i18n/ja/dashboard.ts b/src/lib/i18n/ja/dashboard.ts index 64513eeb..87f0fec6 100644 --- a/src/lib/i18n/ja/dashboard.ts +++ b/src/lib/i18n/ja/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "サンプルエントロピー", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "左右差指標", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG指標", "dashboard.hr": "心拍数", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/ja/history.ts b/src/lib/i18n/ja/history.ts index 370fa7e2..d4a6abcb 100644 --- a/src/lib/i18n/ja/history.ts +++ b/src/lib/i18n/ja/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "ミュー抑制", "sd.laterality": "左右差", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth 活動", "sd.hjorthMob": "Hjorth 移動性", "sd.hjorthCmpl": "Hjorth 複雑性", @@ -95,6 +96,8 @@ const history: Record = { "history.samples": "サンプル数", "history.device": "デバイス", "history.battery": "バッテリー", + "history.chunkCount": "{n}個のチャンク", + "history.chunkCountTooltip": "クラッシュ対策のため{n}ファイルに分割された長時間録音; 期間 {duration}", "history.snr": "信号品質", "history.label": "ラベル", "history.labels": "ラベル", @@ -202,6 +205,7 @@ const history: Record = { "compare.sampleEntropy": "サンプルエントロピー", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "左右差指標", + "compare.echt": "ECHT(α律動性)", "compare.hr": "心拍数", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/ja/llm.ts b/src/lib/i18n/ja/llm.ts index 257c1736..da66acb6 100644 --- a/src/lib/i18n/ja/llm.ts +++ b/src/lib/i18n/ja/llm.ts @@ -83,6 +83,7 @@ const llm: Record = { "llm.section.models": "言語モデル", "llm.section.mmproj": "マルチモーダルプロジェクター", "llm.section.inference": "推論設定", + "llm.section.mtp": "マルチトークン予測", "llm.enabled": "LLMサーバーを有効化", "llm.enabledDesc": "WebSocket APIと同じポートでOpenAI互換推論サーバーを実行します。llm Cargoフィーチャーとダウンロード済みモデルが必要です。", @@ -283,6 +284,10 @@ const llm: Record = { "llm.inference.offloadKqvDesc": "すべてのトランスフォーマーレイヤーがGPUオフロードされていなくても、K/Q/Vテンソル操作をGPUにオフロードします。ハイブリッドCPU+GPUセットアップに推奨。", + "llm.mtp.draftTokens": "ドラフトトークン数", + "llm.mtp.draftTokensDesc": + "デコードステップごとに投機的に生成するトークン数。値が大きいほどスループットが向上しますが、メモリをより多く消費します。MTP対応モデルが必要です。", + "chat.status.running": "実行中", "chat.status.loading": "モデルを読み込み中…", "chat.status.stopped": "サーバー停止中", @@ -485,6 +490,12 @@ const llm: Record = { "model.idleReembedIdle": "アイドル期間を待機中", "search.eegCoverage": "EEGカバレッジ", "search.eegCoverageLabel": "{embedded}件/{total}件 ({pct}%)", + + "model.idleReembedMemoryThrottled": "延期しました — システムメモリ {pct}% (上限 {limit}%)", + "model.maxResidentMemory": "システムメモリ上限", + "model.maxResidentMemoryDesc": + "システムメモリがこの割合を超えた場合、バックグラウンド埋め込みをスキップします。100% でガードを無効化します。", + "model.maxResidentMemoryDisabled": "オフ", }; export default llm; diff --git a/src/lib/i18n/ja/search.ts b/src/lib/i18n/ja/search.ts index 5c39e85f..e6473170 100644 --- a/src/lib/i18n/ja/search.ts +++ b/src/lib/i18n/ja/search.ts @@ -9,6 +9,46 @@ const search: Record = { "embeddings.model": "埋め込みモデル", "embeddings.modelApplied": "埋め込みモデルを適用しました", "embeddings.modelFailed": "モデルの適用に失敗しました", + "embeddings.backend": "埋め込みランタイム", + "embeddings.backendDesc": + "テキスト埋め込みの実行バックエンドを選びます。FastEmbed は ORT を使い、RLX はデーモンが RLX 対応でビルドされている場合に同じ埋め込みグラフをローカル RLX ランタイムで実行します。", + "embeddings.backendFastembed": "FastEmbed / ORT(既定)", + "embeddings.backendFastembedDesc": "既定。一覧のすべてのモデルに対応します。", + "embeddings.backendRlx": "RLX", + "embeddings.backendRlxDesc": "safetensors の BERT/Nomic モデル向けの実験的な高速パス。", + "embeddings.indexBackend": "ラベル検索インデックス", + "embeddings.indexBackendDesc": + "ラベル意味検索に使うローカルベクトルインデックスを選びます。両方構築し、自分のクエリでベンチマークして、データに合った高速または高品質な方を選んでください。", + "embeddings.indexBackend.hnsw": "HNSW", + "embeddings.indexBackend.hnswDesc": "現在の既定。高い再現率、メモリ上のグラフが大きめ。", + "embeddings.indexBackend.turboquant": "TurboQuant インデックス", + "embeddings.indexBackend.turboquantDesc": + "圧縮 TurboVec インデックス。メモリとディスク使用量が少なく、大量ラベル向け。", + "embeddings.indexCurrent": "現在の検索バックエンド: {backend}", + "embeddings.indexBackendApplied": "ラベルインデックスバックエンドを適用しました", + "embeddings.indexBackendFailed": "ラベルインデックスバックエンドの変更に失敗しました", + "embeddings.indexRebuild": "インデックスを再構築", + "embeddings.indexRebuilding": "再構築中…", + "embeddings.indexRebuilt": "ラベルインデックスを再構築しました", + "embeddings.indexRebuildFailed": "ラベルインデックスの再構築に失敗しました", + "embeddings.indexBenchmark": "ベンチマーク", + "embeddings.indexBenchmarking": "ベンチマーク中…", + "embeddings.indexBenchmarkPlaceholder": "ベンチマーク用クエリ(例: 集中したコーディングセッション)", + "embeddings.indexBenchmarkFailed": "ベンチマークに失敗しました", + "embeddings.indexBenchmarkClose": "TurboQuant は HNSW に近い結果", + "embeddings.indexBenchmarkDiverged": "TurboQuant は HNSW と異なる結果", + "embeddings.indexBenchmarkDelta": "コサイン距離デルタ 平均 {avg}、最大 {max}", + "embeddings.indexBenchmarkNoResults": "結果なし", + "embeddings.indexUnavailable": "インデックスを利用できません。先にインデックスを再構築してください。", + "embeddings.indexMemory": "ディスク使用量", + "embeddings.indexMemoryRow": "{backend}: {total} (テキスト {text} · コンテキスト {context} · EEG {eeg})", + "embeddings.indexMemoryTotal": "合計: {total}", + "embeddings.rlxDevice": "RLX デバイス", + "embeddings.rlxMaxSeq": "最大シーケンス", + "embeddings.rlxHint": + "RLX は Hugging Face から tokenizer.json と model.safetensors をダウンロードし、ローカルでベクトルをプーリングして正規化します。", + "embeddings.rlxQuantizedUnsupported": + "量子化 FastEmbed モデルは ORT 専用です。RLX を使うには非量子化の safetensors モデルを選択してください。", "embeddings.info": "埋め込みは各ラベルのテキストとコンテキストに対して生成されます。初回使用時にモデルの重みが一度ダウンロードされ、ローカルにキャッシュされます。小さいモデル(384次元以下)は高速です。大きいモデルはより豊かな表現を生成します。", "embeddings.sharedNote": diff --git a/src/lib/i18n/ja/settings.ts b/src/lib/i18n/ja/settings.ts index 5e63b356..74dac2b6 100644 --- a/src/lib/i18n/ja/settings.ts +++ b/src/lib/i18n/ja/settings.ts @@ -805,6 +805,24 @@ const settings: Record = { "activity.productivePct": "生産的 %", "activity.totalReadingTime": "閲覧時間", "activity.avgScrollDepth": "平均スクロール深度", + + "daemonActivity.title": "デーモンのバックグラウンド処理", + "daemonActivity.intro": + "デーモンがバックグラウンドで実行している定期タスクの一覧。各タスクの動作内容・存在理由・実行頻度を確認できます。使わない監視機能をオフにすれば CPU 負荷を抑えられます。", + "daemonActivity.loading": "読み込み中…", + "daemonActivity.running": "実行中", + "daemonActivity.idle": "待機中", + "daemonActivity.eventDriven": "イベント駆動", + "daemonActivity.whyPrefix": "理由:", + "daemonActivity.costLow": "負荷: 低", + "daemonActivity.costMedium": "負荷: 中", + "daemonActivity.costHigh": "負荷: 高", + "daemonActivity.never": "未実行", + "daemonActivity.lastRanSecondsAgo": "{n} 秒前", + "daemonActivity.lastRanMinutesAgo": "{n} 分前", + "daemonActivity.lastRanHoursAgo": "{n} 時間前", + "daemonActivity.tickDuration": "実行時間: {n} ms", + "daemonActivity.tickCount": "実行回数: {n}", }; export default settings; diff --git a/src/lib/i18n/ja/ui.ts b/src/lib/i18n/ja/ui.ts index 0c887005..9c6422d6 100644 --- a/src/lib/i18n/ja/ui.ts +++ b/src/lib/i18n/ja/ui.ts @@ -36,6 +36,7 @@ const ui: Record = { "tip.sampleEntropy": "サンプルエントロピー — 信号の不規則性。高いほど予測不可能。", "tip.pacThetaGamma": "シータ位相とガンマ振幅の位相振幅結合。記憶のエンコーディングに関連。", "tip.lateralityIndex": "すべての帯域にわたる左右パワー非対称性。正 = 右優位。", + "tip.echt": "端点補正ヒルベルト変換 — α帯リズム性 (0–1)。高 = 強く位相が安定したα振動。[Schreglmann 2021]", "tip.hr": "PPG拍間間隔から導出された心拍数。", "tip.rmssd": "連続する心拍間隔の差の二乗平均平方根。主要な副交感HRV指標。", "tip.sdnn": "拍間間隔の標準偏差。全体的なHRVを反映。", @@ -116,6 +117,12 @@ const ui: Record = { "updates.intervalOffWarning": "自動アップデート確認が無効です。上のボタンを使用して手動で確認してください。", "updates.autostart": "ログイン時に起動", "updates.autostartDesc": "コンピューターにログインしたときに自動的に起動します。", + "updates.autoUpdate": "アップデートを自動的にインストール", + "updates.autoUpdateDesc": + "新しいバージョンをバックグラウンドでダウンロードし、次回の再起動時にインストールします。タイミングを自分で選ぶには無効にしてください。", + "updates.autoUpdateOffNotice": + "自動インストールはオフです — 「インストール」をクリックしてダウンロードと更新を行ってください。", + "updates.installNow": "インストール", "updates.footer": "アップデートは自動的にダウンロードされます。準備ができたら再起動して適用してください。", "whatsNew.title": "新着情報", diff --git a/src/lib/i18n/keys.ts b/src/lib/i18n/keys.ts index 89e36b1f..299387be 100644 --- a/src/lib/i18n/keys.ts +++ b/src/lib/i18n/keys.ts @@ -413,6 +413,7 @@ export type TranslationKey = | "compare.diff" | "compare.drowsiness" | "compare.dtr" + | "compare.echt" | "compare.epochsA" | "compare.epochsB" | "compare.headPitch" @@ -509,6 +510,22 @@ export type TranslationKey = | "daemon.forceRestart" | "daemon.wsClients" | "daemon.wsError" + | "daemonActivity.costHigh" + | "daemonActivity.costLow" + | "daemonActivity.costMedium" + | "daemonActivity.eventDriven" + | "daemonActivity.idle" + | "daemonActivity.intro" + | "daemonActivity.lastRanHoursAgo" + | "daemonActivity.lastRanMinutesAgo" + | "daemonActivity.lastRanSecondsAgo" + | "daemonActivity.loading" + | "daemonActivity.never" + | "daemonActivity.running" + | "daemonActivity.tickCount" + | "daemonActivity.tickDuration" + | "daemonActivity.title" + | "daemonActivity.whyPrefix" | "dashboard.accel" | "dashboard.addLabel" | "dashboard.addNote" @@ -543,6 +560,7 @@ export type TranslationKey = | "dashboard.disconnected" | "dashboard.drowsiness" | "dashboard.dtr" + | "dashboard.echt" | "dashboard.eegChannels" | "dashboard.eegWaveforms" | "dashboard.engagement" @@ -762,6 +780,12 @@ export type TranslationKey = | "embeddings.autoReembed.labels" | "embeddings.autoReembed.screenshots" | "embeddings.autoReembed.title" + | "embeddings.backend" + | "embeddings.backendDesc" + | "embeddings.backendFastembed" + | "embeddings.backendFastembedDesc" + | "embeddings.backendRlx" + | "embeddings.backendRlxDesc" | "embeddings.dimHint" | "embeddings.dimLegend" | "embeddings.info" @@ -772,6 +796,10 @@ export type TranslationKey = | "embeddings.reembedBtn" | "embeddings.reembedDesc" | "embeddings.reembedding" + | "embeddings.rlxDevice" + | "embeddings.rlxHint" + | "embeddings.rlxMaxSeq" + | "embeddings.rlxQuantizedUnsupported" | "embeddings.sharedNote" | "embeddings.stale" | "embeddings.watchdog.desc" @@ -1161,6 +1189,8 @@ export type TranslationKey = | "history.addLabel" | "history.battery" | "history.charts" + | "history.chunkCount" + | "history.chunkCountTooltip" | "history.clearSelection" | "history.compare" | "history.compareSelected" @@ -1372,6 +1402,8 @@ export type TranslationKey = | "llm.inference.parallelDesc" | "llm.inference.prefill" | "llm.inference.prefillDesc" + | "llm.mtp.draftTokens" + | "llm.mtp.draftTokensDesc" | "llm.localPath" | "llm.mmproj" | "llm.mmproj.autoload" @@ -1388,6 +1420,7 @@ export type TranslationKey = | "llm.section.inference" | "llm.section.mmproj" | "llm.section.models" + | "llm.section.mtp" | "llm.section.server" | "llm.size" | "llm.state.cancelled" @@ -1891,6 +1924,7 @@ export type TranslationKey = | "sd.faa" | "sd.gamma" | "sd.higuchiFd" + | "sd.echt" | "sd.hjorthAct" | "sd.hjorthCmpl" | "sd.hjorthMob" @@ -2543,6 +2577,7 @@ export type TranslationKey = | "tip.gamma" | "tip.headache" | "tip.higuchiFd" + | "tip.echt" | "tip.hjorthActivity" | "tip.hjorthComplexity" | "tip.hjorthMobility" @@ -2727,7 +2762,10 @@ export type TranslationKey = | "umapSettings.timeoutDesc" | "updates.autoCheck" | "updates.autoCheckDesc" + | "updates.autoUpdate" + | "updates.autoUpdateDesc" | "updates.autoUpdateFailedOnline" + | "updates.autoUpdateOffNotice" | "updates.autostart" | "updates.autostartDesc" | "updates.available" @@ -2740,6 +2778,7 @@ export type TranslationKey = | "updates.downloadNow" | "updates.downloading" | "updates.footer" + | "updates.installNow" | "updates.installed" | "updates.interval15m" | "updates.interval1h" @@ -2751,6 +2790,8 @@ export type TranslationKey = | "updates.lastChecked" | "updates.openDownloadPageFailed" | "updates.readyToRestart" + | "updates.receivePrereleases" + | "updates.receivePrereleasesDesc" | "updates.restartNow" | "updates.restartToApply" | "updates.restartWhenReady" diff --git a/src/lib/i18n/ko/dashboard.ts b/src/lib/i18n/ko/dashboard.ts index d1f37f6a..42ec9d06 100644 --- a/src/lib/i18n/ko/dashboard.ts +++ b/src/lib/i18n/ko/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "표본 엔트로피", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "편측성", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG 지표", "dashboard.hr": "심박수", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/ko/history.ts b/src/lib/i18n/ko/history.ts index 469e4f07..e7791747 100644 --- a/src/lib/i18n/ko/history.ts +++ b/src/lib/i18n/ko/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "Mu 억제", "sd.laterality": "편측성", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth 활동", "sd.hjorthMob": "Hjorth 이동성", "sd.hjorthCmpl": "Hjorth 복잡도", @@ -95,6 +96,8 @@ const history: Record = { "history.samples": "샘플", "history.device": "기기", "history.battery": "배터리", + "history.chunkCount": "{n}개 청크", + "history.chunkCountTooltip": "충돌 안전을 위해 {n}개 파일로 분할된 긴 녹음; 길이 {duration}", "history.snr": "신호 품질", "history.label": "라벨", "history.labels": "라벨", @@ -202,6 +205,7 @@ const history: Record = { "compare.sampleEntropy": "표본 엔트로피", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "편측성 지수", + "compare.echt": "ECHT (알파 율동성)", "compare.hr": "심박수", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/ko/llm.ts b/src/lib/i18n/ko/llm.ts index 7071f042..e74cff13 100644 --- a/src/lib/i18n/ko/llm.ts +++ b/src/lib/i18n/ko/llm.ts @@ -81,6 +81,7 @@ const llm: Record = { "llm.section.models": "언어 모델", "llm.section.mmproj": "멀티모달 프로젝터", "llm.section.inference": "추론 설정", + "llm.section.mtp": "멀티 토큰 예측", "llm.enabled": "LLM 서버 활성화", "llm.enabledDesc": "WebSocket API와 같은 포트에서 OpenAI 호환 추론 서버를 실행합니다. llm Cargo 기능과 다운로드된 모델이 필요합니다.", @@ -278,6 +279,10 @@ const llm: Record = { "llm.inference.offloadKqvDesc": "모든 트랜스포머 레이어가 GPU에 오프로드되지 않아도 K/Q/V 텐서 연산을 GPU에 오프로드합니다. 하이브리드 CPU+GPU 설정에 권장됩니다.", + "llm.mtp.draftTokens": "드래프트 토큰", + "llm.mtp.draftTokensDesc": + "디코딩 단계마다 투기적으로 생성할 토큰 수입니다. 값이 높을수록 처리량이 증가하지만 메모리를 더 많이 사용합니다. MTP 지원 모델이 필요합니다.", + "chat.status.running": "실행 중", "chat.status.loading": "모델 로딩 중…", "chat.status.stopped": "서버 중지됨", @@ -480,6 +485,12 @@ const llm: Record = { "model.idleReembedIdle": "유휴 기간 대기 중", "search.eegCoverage": "EEG 커버리지", "search.eegCoverageLabel": "{embedded}개/{total}개 ({pct}%)", + + "model.idleReembedMemoryThrottled": "연기됨 — 시스템 메모리 {pct}% (한도 {limit}%)", + "model.maxResidentMemory": "최대 시스템 메모리", + "model.maxResidentMemoryDesc": + "시스템 메모리가 이 비율을 초과하면 백그라운드 임베딩을 건너뜁니다. 100%로 설정하면 비활성화됩니다.", + "model.maxResidentMemoryDisabled": "끔", }; export default llm; diff --git a/src/lib/i18n/ko/search.ts b/src/lib/i18n/ko/search.ts index 1117c2ee..6027b3c2 100644 --- a/src/lib/i18n/ko/search.ts +++ b/src/lib/i18n/ko/search.ts @@ -9,6 +9,45 @@ const search: Record = { "embeddings.model": "임베딩 모델", "embeddings.modelApplied": "임베딩 모델 적용됨", "embeddings.modelFailed": "모델 적용 실패", + "embeddings.backend": "임베딩 런타임", + "embeddings.backendDesc": + "텍스트 임베딩 실행 백엔드를 선택합니다. FastEmbed는 ORT를 사용하고, RLX는 데몬이 RLX 지원으로 빌드된 경우 같은 임베딩 그래프를 로컬 RLX 런타임에서 실행합니다.", + "embeddings.backendFastembed": "FastEmbed / ORT (기본값)", + "embeddings.backendFastembedDesc": "기본값이며 목록의 모든 모델과 호환됩니다.", + "embeddings.backendRlx": "RLX", + "embeddings.backendRlxDesc": "safetensors BERT/Nomic 모델용 실험적 고속 경로입니다.", + "embeddings.indexBackend": "라벨 검색 인덱스", + "embeddings.indexBackendDesc": + "라벨 의미 검색에 사용할 로컬 벡터 인덱스를 선택합니다. 둘 다 구축한 뒤 자신의 쿼리로 벤치마크하고, 데이터에 맞는 더 빠르거나 품질이 높은 옵션을 고르세요.", + "embeddings.indexBackend.hnsw": "HNSW", + "embeddings.indexBackend.hnswDesc": "현재 기본값. 높은 재현율, 메모리 내 그래프가 더 큼.", + "embeddings.indexBackend.turboquant": "TurboQuant 인덱스", + "embeddings.indexBackend.turboquantDesc": "압축된 TurboVec 인덱스. 메모리와 디스크 사용량이 적어 대량 라벨에 적합.", + "embeddings.indexCurrent": "현재 검색 백엔드: {backend}", + "embeddings.indexBackendApplied": "라벨 인덱스 백엔드 적용됨", + "embeddings.indexBackendFailed": "라벨 인덱스 백엔드 변경 실패", + "embeddings.indexRebuild": "인덱스 재구축", + "embeddings.indexRebuilding": "재구축 중…", + "embeddings.indexRebuilt": "라벨 인덱스 재구축 완료", + "embeddings.indexRebuildFailed": "라벨 인덱스 재구축 실패", + "embeddings.indexBenchmark": "벤치마크", + "embeddings.indexBenchmarking": "벤치마크 중…", + "embeddings.indexBenchmarkPlaceholder": "벤치마크 쿼리, 예: 집중 코딩 세션", + "embeddings.indexBenchmarkFailed": "벤치마크 실패", + "embeddings.indexBenchmarkClose": "TurboQuant가 HNSW와 거의 일치", + "embeddings.indexBenchmarkDiverged": "TurboQuant가 HNSW와 다름", + "embeddings.indexBenchmarkDelta": "코사인 거리 델타 평균 {avg}, 최대 {max}", + "embeddings.indexBenchmarkNoResults": "결과 없음", + "embeddings.indexUnavailable": "인덱스를 사용할 수 없습니다. 먼저 인덱스를 재구축하세요.", + "embeddings.indexMemory": "디스크 사용량", + "embeddings.indexMemoryRow": "{backend}: {total} (텍스트 {text} · 컨텍스트 {context} · EEG {eeg})", + "embeddings.indexMemoryTotal": "전체: {total}", + "embeddings.rlxDevice": "RLX 장치", + "embeddings.rlxMaxSeq": "최대 시퀀스", + "embeddings.rlxHint": + "RLX는 Hugging Face에서 tokenizer.json과 model.safetensors를 다운로드한 뒤 로컬에서 벡터를 풀링하고 정규화합니다.", + "embeddings.rlxQuantizedUnsupported": + "양자화된 FastEmbed 모델은 ORT 전용입니다. RLX를 사용하려면 양자화되지 않은 safetensors 모델을 선택하세요.", "embeddings.info": "각 라벨의 텍스트와 컨텍스트에 대해 임베딩이 생성됩니다. 첫 사용 시 모델 가중치가 한 번 다운로드되어 로컬에 캐시됩니다. 소형 모델(≤384d)은 빠르고, 대형 모델은 더 풍부한 표현을 생성합니다.", "embeddings.sharedNote": "이 모델은 앱 전체에서 공유됩니다 — EEG 훅 매칭과 스크린샷 OCR 텍스트 검색에도 사용됩니다.", diff --git a/src/lib/i18n/ko/settings.ts b/src/lib/i18n/ko/settings.ts index 7f95222b..ed3d23c2 100644 --- a/src/lib/i18n/ko/settings.ts +++ b/src/lib/i18n/ko/settings.ts @@ -789,6 +789,24 @@ const settings: Record = { "activity.productivePct": "생산적 %", "activity.totalReadingTime": "읽기 시간", "activity.avgScrollDepth": "평균 스크롤 깊이", + + "daemonActivity.title": "데몬 백그라운드 작업", + "daemonActivity.intro": + "데몬이 백그라운드에서 실행하는 반복 작업 목록입니다. 각 작업의 동작·존재 이유·실행 주기를 확인할 수 있습니다. 사용하지 않는 추적기를 끄면 CPU 부하를 줄일 수 있습니다.", + "daemonActivity.loading": "불러오는 중…", + "daemonActivity.running": "실행 중", + "daemonActivity.idle": "대기 중", + "daemonActivity.eventDriven": "이벤트 기반", + "daemonActivity.whyPrefix": "이유:", + "daemonActivity.costLow": "부하 낮음", + "daemonActivity.costMedium": "부하 보통", + "daemonActivity.costHigh": "부하 높음", + "daemonActivity.never": "실행되지 않음", + "daemonActivity.lastRanSecondsAgo": "{n}초 전", + "daemonActivity.lastRanMinutesAgo": "{n}분 전", + "daemonActivity.lastRanHoursAgo": "{n}시간 전", + "daemonActivity.tickDuration": "소요 시간: {n} ms", + "daemonActivity.tickCount": "실행 횟수: {n}", }; export default settings; diff --git a/src/lib/i18n/ko/ui.ts b/src/lib/i18n/ko/ui.ts index efa9c981..ff46fd20 100644 --- a/src/lib/i18n/ko/ui.ts +++ b/src/lib/i18n/ko/ui.ts @@ -36,6 +36,8 @@ const ui: Record = { "tip.sampleEntropy": "표본 엔트로피 — 신호의 불규칙성. 높을수록 예측 불가능.", "tip.pacThetaGamma": "세타 위상과 감마 진폭 간 위상-진폭 커플링. 기억 부호화와 연관됩니다.", "tip.lateralityIndex": "모든 대역에 걸친 좌우 파워 비대칭. 양수 = 우측 우세.", + "tip.echt": + "끝점 보정 힐베르트 변환 — 알파 대역 리듬성 (0–1). 높음 = 강하고 위상이 안정된 알파 진동. [Schreglmann 2021]", "tip.hr": "PPG 심박 간격에서 유래한 심박수.", "tip.rmssd": "연속 심박 간격 차이의 제곱평균제곱근. 핵심 부교감 HRV 지표.", "tip.sdnn": "박동 간 간격의 표준편차. 전체 HRV를 반영합니다.", @@ -112,6 +114,11 @@ const ui: Record = { "updates.intervalOffWarning": "자동 업데이트 확인이 비활성화되었습니다. 위의 버튼으로 수동 확인하세요.", "updates.autostart": "로그인 시 시작", "updates.autostartDesc": "컴퓨터에 로그인할 때 자동으로 시작합니다.", + "updates.autoUpdate": "업데이트 자동 설치", + "updates.autoUpdateDesc": + "백그라운드에서 새 버전을 다운로드하고 다음 재시작 시 설치합니다. 설치 시점을 직접 선택하려면 끄세요.", + "updates.autoUpdateOffNotice": "자동 설치가 꺼져 있습니다 — 다운로드 및 업데이트하려면 설치를 클릭하세요.", + "updates.installNow": "설치", "updates.footer": "업데이트는 자동으로 다운로드됩니다. 적용 준비가 되면 재시작하세요.", "whatsNew.title": "새로운 기능", diff --git a/src/lib/i18n/uk/dashboard.ts b/src/lib/i18n/uk/dashboard.ts index d66d016d..00efbe8c 100644 --- a/src/lib/i18n/uk/dashboard.ts +++ b/src/lib/i18n/uk/dashboard.ts @@ -54,6 +54,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "Вибірк. ентропія", "dashboard.pacThetaGamma": "ФАЗ (θ–γ)", "dashboard.lateralityIndex": "Латеральність", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG метрики", "dashboard.hr": "Пульс", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/uk/history.ts b/src/lib/i18n/uk/history.ts index e7dfc567..1f860f45 100644 --- a/src/lib/i18n/uk/history.ts +++ b/src/lib/i18n/uk/history.ts @@ -66,6 +66,7 @@ const history: Record = { "compare.sampleEntropy": "Вибіркова ентропія", "compare.pacThetaGamma": "ФАЗ (θ–γ)", "compare.lateralityIndex": "Індекс латеральності", + "compare.echt": "ECHT (ритмічність альфа)", "compare.hr": "Пульс", "compare.rmssd": "RMSSD (ВСР)", "compare.sdnn": "SDNN (ВСР)", @@ -144,6 +145,7 @@ const history: Record = { "sd.muSupp": "Придуш. Мю", "sd.laterality": "Латеральність", "sd.pac": "ФАЗ θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth Акт.", "sd.hjorthMob": "Hjorth Моб.", "sd.hjorthCmpl": "Hjorth Скл.", @@ -196,6 +198,8 @@ const history: Record = { "history.samples": "Зразки", "history.device": "Пристрій", "history.battery": "Батарея", + "history.chunkCount": "{n} фрагментів", + "history.chunkCountTooltip": "Довгий запис розділено на {n} файлів для безпеки; тривалість {duration}", "history.snr": "Якість сигналу", "history.label": "мітка", "history.labels": "мітки", diff --git a/src/lib/i18n/uk/llm.ts b/src/lib/i18n/uk/llm.ts index decc7e0f..4fd0bc89 100644 --- a/src/lib/i18n/uk/llm.ts +++ b/src/lib/i18n/uk/llm.ts @@ -81,6 +81,7 @@ const llm: Record = { "llm.section.models": "Мовні моделі", "llm.section.mmproj": "Мультимодальні проектори", "llm.section.inference": "Налаштування інференсу", + "llm.section.mtp": "Багатотокенне прогнозування", "llm.enabled": "Увімкнути LLM сервер", "llm.enabledDesc": "Запустити OpenAI-сумісний сервер інференсу на тому ж порту, що й WebSocket API. Потрібен Cargo feature llm і завантажена модель.", @@ -273,6 +274,10 @@ const llm: Record = { "llm.inference.offloadKqv": "Вивантажити KQV на GPU", "llm.inference.offloadKqvDesc": "Вивантажити тензорні операції K/Q/V на GPU, навіть якщо не всі шари вивантажені.", + "llm.mtp.draftTokens": "Токени чернетки", + "llm.mtp.draftTokensDesc": + "Кількість токенів, що генеруються спекулятивно на кожному кроці декодування. Більші значення підвищують пропускну здатність, але потребують більше пам'яті. Потребує MTP-сумісну модель.", + "llm.hfSearch.title": "Пошук моделей на HuggingFace", "llm.hfSearch.placeholder": "Шукати GGUF-моделі на HuggingFace…", "llm.hfSearch.searchBtn": "Шукати", @@ -478,6 +483,12 @@ const llm: Record = { "model.idleReembedIdle": "Очікування періоду простою", "search.eegCoverage": "Покриття ЕЕГ", "search.eegCoverageLabel": "{embedded} з {total} ({pct}%)", + + "model.idleReembedMemoryThrottled": "Відкладено — пам'ять системи {pct}% (ліміт {limit}%)", + "model.maxResidentMemory": "Макс. пам'ять системи", + "model.maxResidentMemoryDesc": + "Пропускати фонове вбудовування, коли пам'ять системи перевищує цю частку. 100% вимикає захист.", + "model.maxResidentMemoryDisabled": "вимк.", }; export default llm; diff --git a/src/lib/i18n/uk/search.ts b/src/lib/i18n/uk/search.ts index 4abb8bb7..03f09d62 100644 --- a/src/lib/i18n/uk/search.ts +++ b/src/lib/i18n/uk/search.ts @@ -9,6 +9,43 @@ const search: Record = { "embeddings.model": "Модель вбудовування", "embeddings.modelApplied": "Модель ембедингів застосовано", "embeddings.modelFailed": "Не вдалося застосувати модель", + "embeddings.backend": "Рантайм ембедингів", + "embeddings.backendDesc": + "Виберіть backend виконання для текстових ембедингів. FastEmbed використовує ORT; RLX запускає ті самі графи ембедингів через локальний рантайм RLX, якщо демон зібрано з підтримкою RLX.", + "embeddings.backendFastembed": "FastEmbed / ORT (типово)", + "embeddings.backendFastembedDesc": "Типово, сумісно з усіма моделями у списку.", + "embeddings.backendRlx": "RLX", + "embeddings.backendRlxDesc": "Експериментальний швидший шлях для safetensors BERT/Nomic моделей.", + "embeddings.indexBackend": "Індекс пошуку міток", + "embeddings.indexBackendDesc": + "Виберіть, який локальний векторний індекс обслуговує семантичний пошук міток. Побудуйте обидва, порівняйте на власному запиті, а потім виберіть швидший або якісніший варіант для ваших даних.", + "embeddings.indexBackend.hnsw": "HNSW", + "embeddings.indexBackend.hnswDesc": "Поточний типовий варіант. Висока повнота, більший граф у пам'яті.", + "embeddings.indexBackend.turboquant": "Індекс TurboQuant", + "embeddings.indexBackend.turboquantDesc": + "Стиснений індекс TurboVec. Менше використання пам'яті та диска, добре для великих наборів міток.", + "embeddings.indexCurrent": "Поточний backend пошуку: {backend}", + "embeddings.indexBackendApplied": "Backend індексу міток застосовано", + "embeddings.indexBackendFailed": "Не вдалося змінити backend індексу міток", + "embeddings.indexRebuild": "Перебудувати індекси", + "embeddings.indexRebuilding": "Перебудова…", + "embeddings.indexRebuilt": "Індекси міток перебудовано", + "embeddings.indexRebuildFailed": "Не вдалося перебудувати індекси міток", + "embeddings.indexBenchmark": "Порівняння швидкодії", + "embeddings.indexBenchmarking": "Виконується benchmark…", + "embeddings.indexBenchmarkPlaceholder": "Запит для benchmark, напр. зосереджена сесія кодування", + "embeddings.indexBenchmarkFailed": "Benchmark не вдався", + "embeddings.indexBenchmarkClose": "TurboQuant близько збігається з HNSW", + "embeddings.indexBenchmarkDiverged": "TurboQuant відрізняється від HNSW", + "embeddings.indexBenchmarkDelta": "дельта косинусної відстані сер. {avg}, макс. {max}", + "embeddings.indexBenchmarkNoResults": "Немає результатів", + "embeddings.indexUnavailable": "Індекс недоступний. Спочатку перебудуйте індекси.", + "embeddings.rlxDevice": "Пристрій RLX", + "embeddings.rlxMaxSeq": "Макс. послідовність", + "embeddings.rlxHint": + "RLX завантажує tokenizer.json і model.safetensors з Hugging Face, а потім локально пулить і нормалізує вектори.", + "embeddings.rlxQuantizedUnsupported": + "Квантовані моделі FastEmbed специфічні для ORT. Виберіть неквантовану safetensors модель, щоб використовувати RLX.", "embeddings.info": "Вбудовування генеруються для тексту та контексту кожної мітки. При першому використанні ваги моделі завантажуються один раз і кешуються локально. Менші моделі (≤384d) швидші; більші дають насиченіші представлення.", "embeddings.sharedNote": @@ -298,6 +335,11 @@ const search: Record = { "search.nodeScreenshotsTip": "Знімки біля збігів", "search.maxTokens": "Токени", + + // ── Auto-synced from en/ (2026-05-19) ── + "embeddings.indexMemory": "Розмір на диску", + "embeddings.indexMemoryRow": "{backend}: {total} ({text} текст · {context} контекст · {eeg} ЕЕГ)", + "embeddings.indexMemoryTotal": "Усього: {total}", }; export default search; diff --git a/src/lib/i18n/uk/settings.ts b/src/lib/i18n/uk/settings.ts index 9f975567..e42b4639 100644 --- a/src/lib/i18n/uk/settings.ts +++ b/src/lib/i18n/uk/settings.ts @@ -824,6 +824,24 @@ const settings: Record = { "activity.productivePct": "продуктивні %", "activity.totalReadingTime": "час читання", "activity.avgScrollDepth": "сер. глибина прокрутки", + + "daemonActivity.title": "Фонова активність демона", + "daemonActivity.intro": + "Повторювані завдання, які демон виконує у фоні — що вони роблять, для чого потрібні та як часто запускаються. Вимкни непотрібні відстежувачі, щоб зменшити навантаження на процесор.", + "daemonActivity.loading": "Завантаження…", + "daemonActivity.running": "активний", + "daemonActivity.idle": "неактивний", + "daemonActivity.eventDriven": "за подіями", + "daemonActivity.whyPrefix": "Чому:", + "daemonActivity.costLow": "низьке навантаження", + "daemonActivity.costMedium": "середнє навантаження", + "daemonActivity.costHigh": "високе навантаження", + "daemonActivity.never": "ще не виконувався", + "daemonActivity.lastRanSecondsAgo": "{n} с тому", + "daemonActivity.lastRanMinutesAgo": "{n} хв тому", + "daemonActivity.lastRanHoursAgo": "{n} год тому", + "daemonActivity.tickDuration": "тривалість: {n} мс", + "daemonActivity.tickCount": "цикли: {n}", }; export default settings; diff --git a/src/lib/i18n/uk/ui.ts b/src/lib/i18n/uk/ui.ts index 0aff8697..f6083040 100644 --- a/src/lib/i18n/uk/ui.ts +++ b/src/lib/i18n/uk/ui.ts @@ -44,6 +44,8 @@ const ui: Record = { "tip.sampleEntropy": "Вибіркова ентропія — нерегулярність сигналу. Вище = менш передбачуваний.", "tip.pacThetaGamma": "Фазово-амплітудне зв'язування тета–гамма. Пов'язане з кодуванням пам'яті.", "tip.lateralityIndex": "Ліво-права асиметрія потужності. Позитивне = правобічна домінантність.", + "tip.echt": + "Гільбертове перетворення з кінцевою корекцією — ритмічність альфа-діапазону (0–1). Високе = сильні фазово-стабільні альфа-коливання. [Schreglmann 2021]", "tip.hr": "Частота серцевих скорочень з міжудкових інтервалів PPG.", "tip.rmssd": "Середньоквадратичне послідовних різниць. Ключова парасимпатична метрика ВСР.", "tip.sdnn": "Стандартне відхилення інтервалів між ударами. Відображає загальну ВСР.", @@ -209,6 +211,12 @@ const ui: Record = { "updates.intervalOffWarning": "Автоматичну перевірку вимкнено. Скористайтесь кнопкою вище для перевірки вручну.", "updates.autostart": "Запуск під час входу", "updates.autostartDesc": "Запускається автоматично при вході в систему.", + "updates.autoUpdate": "Встановлювати оновлення автоматично", + "updates.autoUpdateDesc": + "Завантажувати нові версії у фоні та встановлювати під час наступного перезапуску. Вимкніть, щоб обирати момент встановлення вручну.", + "updates.autoUpdateOffNotice": + "Автоматичне встановлення вимкнено — натисніть «Встановити», щоб завантажити та оновити.", + "updates.installNow": "Встановити", "updates.autoCheckDesc": "Перевіряти оновлення раз на день під час запуску застосунку.", "updates.footer": "Оновлення завантажуються автоматично. Перезапустіть, коли будете готові.", diff --git a/src/lib/i18n/zh/dashboard.ts b/src/lib/i18n/zh/dashboard.ts index 7ec5626f..f539eb46 100644 --- a/src/lib/i18n/zh/dashboard.ts +++ b/src/lib/i18n/zh/dashboard.ts @@ -57,6 +57,7 @@ const dashboard: Record = { "dashboard.sampleEntropy": "样本熵", "dashboard.pacThetaGamma": "PAC (θ–γ)", "dashboard.lateralityIndex": "偏侧性", + "dashboard.echt": "ECHT", "dashboard.ppgMetrics": "PPG 指标", "dashboard.hr": "心率", "dashboard.rmssd": "RMSSD", diff --git a/src/lib/i18n/zh/history.ts b/src/lib/i18n/zh/history.ts index 2f064fb9..93ed5b6f 100644 --- a/src/lib/i18n/zh/history.ts +++ b/src/lib/i18n/zh/history.ts @@ -43,6 +43,7 @@ const history: Record = { "sd.muSupp": "Mu 抑制", "sd.laterality": "偏侧性", "sd.pac": "PAC θ-γ", + "sd.echt": "ECHT", "sd.hjorthAct": "Hjorth 活动度", "sd.hjorthMob": "Hjorth 移动度", "sd.hjorthCmpl": "Hjorth 复杂度", @@ -95,6 +96,8 @@ const history: Record = { "history.samples": "采样数", "history.device": "设备", "history.battery": "电量", + "history.chunkCount": "{n} 个分段", + "history.chunkCountTooltip": "为防止崩溃,长录制被分为 {n} 个文件;时长 {duration}", "history.snr": "信号质量", "history.label": "标签", "history.labels": "标签", @@ -201,6 +204,7 @@ const history: Record = { "compare.sampleEntropy": "样本熵", "compare.pacThetaGamma": "PAC (θ–γ)", "compare.lateralityIndex": "偏侧性指数", + "compare.echt": "ECHT(α 节律性)", "compare.hr": "心率", "compare.rmssd": "RMSSD (HRV)", "compare.sdnn": "SDNN (HRV)", diff --git a/src/lib/i18n/zh/llm.ts b/src/lib/i18n/zh/llm.ts index 14a956cd..56b86c1e 100644 --- a/src/lib/i18n/zh/llm.ts +++ b/src/lib/i18n/zh/llm.ts @@ -81,6 +81,7 @@ const llm: Record = { "llm.section.models": "语言模型", "llm.section.mmproj": "多模态投影器", "llm.section.inference": "推理设置", + "llm.section.mtp": "多令牌预测", "llm.enabled": "启用 LLM 服务器", "llm.enabledDesc": "在与 WebSocket API 相同的端口上运行 OpenAI 兼容的推理服务器。需要 llm Cargo 功能和已下载的模型。", "llm.autostart": "启动时自动加载", @@ -270,6 +271,9 @@ const llm: Record = { "llm.inference.offloadKqvDesc": "即使并非所有 transformer 层都在 GPU 上,也将 K/Q/V 张量运算卸载到 GPU。推荐用于混合 CPU+GPU 设置。", + "llm.mtp.draftTokens": "草稿令牌数", + "llm.mtp.draftTokensDesc": "每个解码步骤投机生成的令牌数。值越大吞吐量越高,但需要更多内存。需要支持 MTP 的模型。", + "chat.status.running": "运行中", "chat.status.loading": "正在加载模型…", "chat.status.stopped": "服务器已停止", @@ -470,6 +474,11 @@ const llm: Record = { "model.idleReembedIdle": "等待空闲时段", "search.eegCoverage": "EEG 覆盖率", "search.eegCoverageLabel": "{embedded}/{total}({pct}%)", + + "model.idleReembedMemoryThrottled": "已延后 — 系统内存 {pct}%(上限 {limit}%)", + "model.maxResidentMemory": "最大系统内存", + "model.maxResidentMemoryDesc": "当系统内存超过该比例时跳过后台嵌入。100% 表示关闭此保护。", + "model.maxResidentMemoryDisabled": "关", }; export default llm; diff --git a/src/lib/i18n/zh/search.ts b/src/lib/i18n/zh/search.ts index 5d13b82f..3a7a817e 100644 --- a/src/lib/i18n/zh/search.ts +++ b/src/lib/i18n/zh/search.ts @@ -9,6 +9,43 @@ const search: Record = { "embeddings.model": "嵌入模型", "embeddings.modelApplied": "嵌入模型已应用", "embeddings.modelFailed": "应用模型失败", + "embeddings.backend": "嵌入运行时", + "embeddings.backendDesc": + "选择文本嵌入的执行后端。FastEmbed 使用 ORT;如果守护进程构建时启用了 RLX,RLX 会通过本地 RLX 运行时执行相同的嵌入图。", + "embeddings.backendFastembed": "FastEmbed / ORT(默认)", + "embeddings.backendFastembedDesc": "默认选项,兼容列表中的所有模型。", + "embeddings.backendRlx": "RLX", + "embeddings.backendRlxDesc": "面向 safetensors BERT/Nomic 模型的实验性高速路径。", + "embeddings.indexBackend": "标签搜索索引", + "embeddings.indexBackendDesc": + "选择为标签语义搜索提供支持的本地向量索引。可先构建两者,用自己的查询做基准测试,再为数据选择更快或更高质量的方案。", + "embeddings.indexBackend.hnsw": "HNSW", + "embeddings.indexBackend.hnswDesc": "当前默认。召回率高,内存中的图较大。", + "embeddings.indexBackend.turboquant": "TurboQuant 索引", + "embeddings.indexBackend.turboquantDesc": "压缩的 TurboVec 索引。内存和磁盘占用更低,适合大量标签。", + "embeddings.indexCurrent": "当前搜索后端:{backend}", + "embeddings.indexBackendApplied": "已应用标签索引后端", + "embeddings.indexBackendFailed": "更改标签索引后端失败", + "embeddings.indexRebuild": "重建索引", + "embeddings.indexRebuilding": "重建中…", + "embeddings.indexRebuilt": "标签索引已重建", + "embeddings.indexRebuildFailed": "重建标签索引失败", + "embeddings.indexBenchmark": "基准测试", + "embeddings.indexBenchmarking": "基准测试中…", + "embeddings.indexBenchmarkPlaceholder": "基准测试查询,例如专注编码会话", + "embeddings.indexBenchmarkFailed": "基准测试失败", + "embeddings.indexBenchmarkClose": "TurboQuant 与 HNSW 结果接近", + "embeddings.indexBenchmarkDiverged": "TurboQuant 与 HNSW 结果不同", + "embeddings.indexBenchmarkDelta": "余弦距离差 平均 {avg},最大 {max}", + "embeddings.indexBenchmarkNoResults": "无结果", + "embeddings.indexUnavailable": "索引不可用。请先重建索引。", + "embeddings.indexMemory": "磁盘占用", + "embeddings.indexMemoryRow": "{backend}:{total}(文本 {text} · 上下文 {context} · EEG {eeg})", + "embeddings.indexMemoryTotal": "总计:{total}", + "embeddings.rlxDevice": "RLX 设备", + "embeddings.rlxMaxSeq": "最大序列", + "embeddings.rlxHint": "RLX 会从 Hugging Face 下载 tokenizer.json 和 model.safetensors,然后在本地池化并归一化向量。", + "embeddings.rlxQuantizedUnsupported": "量化 FastEmbed 模型是 ORT 专用的。请选择非量化 safetensors 模型以使用 RLX。", "embeddings.info": "系统会为每个标签的文本和上下文生成嵌入向量。首次使用时,模型权重会下载一次并缓存到本地。较小的模型(≤384维)速度快;较大的模型生成更丰富的表示。", "embeddings.sharedNote": "此模型在整个应用中共享——它也用于 EEG 钩子匹配和截图 OCR 文本搜索。", diff --git a/src/lib/i18n/zh/settings.ts b/src/lib/i18n/zh/settings.ts index 347b99ac..9e1be7b4 100644 --- a/src/lib/i18n/zh/settings.ts +++ b/src/lib/i18n/zh/settings.ts @@ -777,6 +777,24 @@ const settings: Record = { "activity.productivePct": "高效 %", "activity.totalReadingTime": "阅读时间", "activity.avgScrollDepth": "平均滚动深度", + + "daemonActivity.title": "守护进程后台活动", + "daemonActivity.intro": + "守护进程在后台运行的定期任务列表:每个任务做什么、为何存在、多久执行一次。关闭不使用的追踪器即可降低 CPU 占用。", + "daemonActivity.loading": "加载中…", + "daemonActivity.running": "运行中", + "daemonActivity.idle": "空闲", + "daemonActivity.eventDriven": "事件驱动", + "daemonActivity.whyPrefix": "原因:", + "daemonActivity.costLow": "负载低", + "daemonActivity.costMedium": "负载中", + "daemonActivity.costHigh": "负载高", + "daemonActivity.never": "尚未运行", + "daemonActivity.lastRanSecondsAgo": "{n} 秒前", + "daemonActivity.lastRanMinutesAgo": "{n} 分钟前", + "daemonActivity.lastRanHoursAgo": "{n} 小时前", + "daemonActivity.tickDuration": "耗时 {n} ms", + "daemonActivity.tickCount": "已执行 {n} 次", }; export default settings; diff --git a/src/lib/i18n/zh/ui.ts b/src/lib/i18n/zh/ui.ts index 466f2020..0f1ed6a4 100644 --- a/src/lib/i18n/zh/ui.ts +++ b/src/lib/i18n/zh/ui.ts @@ -36,6 +36,7 @@ const ui: Record = { "tip.sampleEntropy": "样本熵 — 信号的不规则性。数值越高 = 越不可预测。", "tip.pacThetaGamma": "Theta 相位与 Gamma 幅度之间的相幅耦合。与记忆编码相关。", "tip.lateralityIndex": "所有频段的左右功率不对称性。正值 = 右侧主导。", + "tip.echt": "端点校正希尔伯特变换 — α 频段节律性 (0–1)。高 = α 振荡强且相位稳定。[Schreglmann 2021]", "tip.hr": "由 PPG 搏动间期推导的心率。", "tip.rmssd": "连续心搏差异的均方根。关键副交感 HRV 指标。", "tip.sdnn": "逐搏间期的标准差。反映整体 HRV。", @@ -114,6 +115,10 @@ const ui: Record = { "updates.intervalOffWarning": "已禁用自动更新检查。请使用上方按钮手动检查。", "updates.autostart": "登录时启动", "updates.autostartDesc": "登录计算机时自动启动。", + "updates.autoUpdate": "自动安装更新", + "updates.autoUpdateDesc": "在后台下载新版本,并在下次重启时安装。关闭后可自行选择安装时机。", + "updates.autoUpdateOffNotice": "自动安装已关闭 — 点击“安装”以下载并更新。", + "updates.installNow": "安装", "updates.footer": "更新会自动下载。准备好后重启即可应用。", "whatsNew.title": "新功能", diff --git a/src/lib/llm/LlmInferenceSection.svelte b/src/lib/llm/LlmInferenceSection.svelte index 10bb2236..09621e1a 100644 --- a/src/lib/llm/LlmInferenceSection.svelte +++ b/src/lib/llm/LlmInferenceSection.svelte @@ -7,6 +7,7 @@ import { ToggleRow } from "$lib/components/ui/toggle-row"; import { t } from "$lib/i18n/index.svelte"; interface LlmConfigView { + runtime: "llama_cpp" | "rlx"; n_gpu_layers: number; ctx_size: number | null; n_batch: number | null; @@ -47,6 +48,7 @@ interface Props { onSetNUbatch: (val: number | null) => void | Promise; onToggleFlashAttention: () => void | Promise; onToggleOffloadKqv: () => void | Promise; + onSetRuntime: (val: "llama_cpp" | "rlx") => void | Promise; } let { @@ -71,6 +73,7 @@ let { onSetNUbatch, onToggleFlashAttention, onToggleOffloadKqv, + onSetRuntime, }: Props = $props(); const KV_TYPES = [ @@ -119,6 +122,31 @@ const curlSnippet = $derived( {#if showAdvanced} + +
+
+ Inference runtime + + {config.runtime === "rlx" ? "RLX (experimental)" : "llama.cpp"} + +
+

+ llama.cpp is the production backend. RLX is the in-tree + inference engine — supports the full LLM catalog plus + MiniMax / LFM / Nemotron-H families that llama.cpp doesn't. +

+
+ {#each [["llama_cpp", "llama.cpp"], ["rlx", "RLX"]] as [val, label]} + + {/each} +
+
{t("llm.inference.gpuLayers")} diff --git a/src/lib/llm/LlmMtpSection.svelte b/src/lib/llm/LlmMtpSection.svelte new file mode 100644 index 00000000..da33f3e3 --- /dev/null +++ b/src/lib/llm/LlmMtpSection.svelte @@ -0,0 +1,79 @@ + + + + +
+ + + {#if showSection} + + +
+
+ {t("llm.mtp.draftTokens")} + + {mtpDraftCount === 0 ? "Off" : mtpDraftCount} + +
+

{t("llm.mtp.draftTokensDesc")}

+
+ {#each draftOptions as [val, label]} + + {/each} +
+
+
+
+ {/if} +
diff --git a/src/lib/llm/llm-helpers.ts b/src/lib/llm/llm-helpers.ts index 2a81942c..48edb008 100644 --- a/src/lib/llm/llm-helpers.ts +++ b/src/lib/llm/llm-helpers.ts @@ -19,6 +19,7 @@ export interface LlmModelEntry { family_desc: string; tags: string[]; is_mmproj: boolean; + mtp: boolean; recommended: boolean; advanced: boolean; params_b: number; diff --git a/src/lib/settings/ActivityTab.svelte b/src/lib/settings/ActivityTab.svelte index 6e7db1bd..bff923ec 100644 --- a/src/lib/settings/ActivityTab.svelte +++ b/src/lib/settings/ActivityTab.svelte @@ -504,9 +504,9 @@ let heatmapMax = $state(1);
{#each tfb as row}
-
{row.focus_level} focus
+
{row.focus_level} focus
{Math.round(row.fail_rate * 100)}%
-
fail rate ({row.passes + row.fails} runs)
+
fail rate ({row.passes + row.fails} runs)
{/each}
@@ -526,7 +526,7 @@ let heatmapMax = $state(1);
{Math.round(row.avg_focus)} - undo {row.undo_rate.toFixed(1)} + undo {row.undo_rate.toFixed(1)}
{/each}
@@ -542,7 +542,7 @@ let heatmapMax = $state(1);
{row.app} {row.delta > 0 ? '+' : ''}{row.delta.toFixed(1)} - vs {Math.round(ai.baseline_focus)} baseline ({row.message_count} msgs) + vs {Math.round(ai.baseline_focus)} baseline ({row.message_count} msgs)
{/each}
@@ -574,7 +574,7 @@ let heatmapMax = $state(1); {@const maxChurn = Math.max(1, ...hp.map((r: any) => r.churn))}
- {row.hour} + {row.hour}
{/each}
@@ -624,7 +624,7 @@ let heatmapMax = $state(1);
-

+

Today

@@ -711,7 +711,7 @@ let heatmapMax = $state(1);
{/each}
-
+
06121823
@@ -721,7 +721,7 @@ let heatmapMax = $state(1);
-

+

Code

@@ -1006,7 +1006,7 @@ let heatmapMax = $state(1);
-

+

Terminal

@@ -1031,12 +1031,12 @@ let heatmapMax = $state(1); {:else if cmd.exit_code === 0}ok {:else}!{/if} - {cmd.category} + {cmd.category} {cmd.command} {#if cmd.eeg_focus != null} - {Math.round(cmd.eeg_focus)} + {Math.round(cmd.eeg_focus)} {/if} - {cmd.cwd?.split("/").pop()} + {cmd.cwd?.split("/").pop()}
{/each}
@@ -1062,7 +1062,7 @@ let heatmapMax = $state(1); {delta > 0 ? "+" : ""}{delta.toFixed(1)} {row.cmd_count}x {#if total > 0} - {Math.round((row.pass_count / total) * 100)}% + {Math.round((row.pass_count / total) * 100)}% {/if}
@@ -1131,7 +1131,7 @@ let heatmapMax = $state(1);
-

+

AI & Web

@@ -1182,10 +1182,10 @@ let heatmapMax = $state(1);
{new Date(msg.at * 1000).toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"})} {msg.role === "user" ? "\u276F" : msg.role === "tool" ? "\u2699" : "\u2190"} - {msg.app} + {msg.app} {msg.text?.slice(0, 120) ?? ""} {#if msg.eeg_focus != null} - {Math.round(msg.eeg_focus)} + {Math.round(msg.eeg_focus)} {/if}
{/each} @@ -1220,11 +1220,11 @@ let heatmapMax = $state(1);

{browserDistraction.suggestion}

{#if !feedbackSent["distraction"]}
- - + +
{:else} - {feedbackSent["distraction"] === "yay" ? "✓" : "✗"} noted + {feedbackSent["distraction"] === "yay" ? "✓" : "✗"} noted {/if}
@@ -1298,10 +1298,10 @@ let heatmapMax = $state(1); {t("activity.notStuck")} {/if} {#if !feedbackSent["research_stuck"]} - - + + {:else} - {feedbackSent["research_stuck"] === "yay" ? "✓" : "✗"} + {feedbackSent["research_stuck"] === "yay" ? "✓" : "✗"} {/if}
@@ -1323,11 +1323,11 @@ let heatmapMax = $state(1);

{browserProcrast.suggestion}

{#if !feedbackSent["procrastination"]}
- - + +
{:else} - {feedbackSent["procrastination"] === "yay" ? "✓" : "✗"} noted + {feedbackSent["procrastination"] === "yay" ? "✓" : "✗"} noted {/if}
@@ -1487,11 +1487,11 @@ let heatmapMax = $state(1);
- {tl.tab_count} + {tl.tab_count}
{/each}
-
+
fewer tabsmore tabs
@@ -1511,7 +1511,7 @@ let heatmapMax = $state(1); title="{hn.hour}:00 — focus {hn.avg_focus.toFixed(0)}, {hn.events} events">
{/each}
-
+
0:0012:0023:00
@@ -1589,7 +1589,7 @@ let heatmapMax = $state(1);
-

+

Trends

diff --git a/src/lib/settings/AppearanceTab.svelte b/src/lib/settings/AppearanceTab.svelte index 38d62307..4ccf3330 100644 --- a/src/lib/settings/AppearanceTab.svelte +++ b/src/lib/settings/AppearanceTab.svelte @@ -174,7 +174,7 @@ const THEME_OPTIONS: { value: ThemeMode; icon: string; labelKey: string }[] = [
- {EEG_CH[i]} + {EEG_CH[i]}
{/each} @@ -184,7 +184,7 @@ const THEME_OPTIONS: { value: ThemeMode; icon: string; labelKey: string }[] = [
- + {["δ","θ","α","β","γ"][i]}
diff --git a/src/lib/settings/ClientsTab.svelte b/src/lib/settings/ClientsTab.svelte index b9715aba..e9710177 100644 --- a/src/lib/settings/ClientsTab.svelte +++ b/src/lib/settings/ClientsTab.svelte @@ -470,7 +470,7 @@ onDestroy(() => stopPolling());
{g.label} - dangerous + dangerous
{g.description}
diff --git a/src/lib/settings/DevicesTab.svelte b/src/lib/settings/DevicesTab.svelte index 0f4ef303..73cb04ec 100644 --- a/src/lib/settings/DevicesTab.svelte +++ b/src/lib/settings/DevicesTab.svelte @@ -779,7 +779,7 @@ onDestroy(() => { bg-clip-text text-transparent"> {pairedDevices.length} - + {t("devices.pairedCount", { n: String(pairedDevices.length) })}
@@ -804,7 +804,7 @@ onDestroy(() => {
- 🔬 {t("devices.virtualDevices")}
{:else if i > 0} @@ -860,10 +860,10 @@ onDestroy(() => {
- + 🔬 {t("devices.virtualDevices")} - — {t("devices.virtualDevicesHint")} + — {t("devices.virtualDevicesHint")}
{#each discoveredVirtual as dev, i (dev.id)} {#if i > 0}{/if} @@ -880,10 +880,10 @@ onDestroy(() => { {(discoveredReal.length > 0 || discoveredVirtual.length > 0) ? 'border-t border-border dark:border-white/[0.06]' : ''}"> - + 🧭 {t("devices.manualHints")} - — {t("devices.manualHintsHint")} + — {t("devices.manualHintsHint")}
{#each manualHintDevices as dev, i (dev.id)} {#if i > 0}{/if} @@ -947,7 +947,7 @@ onDestroy(() => {
{t(item.name_key)} {#if item.ios_only} - 📱 {t("devices.iosOnly")} + 📱 {t("devices.iosOnly")} {/if}
{/each} @@ -1766,13 +1766,13 @@ onDestroy(() => { {/if} {#if isVirtualDevice(dev)} 🔬 {t("devices.virtualBadge")} {:else if dev.transport && dev.transport !== "ble"} ({ model_backend: "zuna", luna_variant: "base", luna_hf_repo: "PulpBio/LUNA", + eegdino_variant: "small", + eegdino_hf_repo: "eugenehp/eegdino", }); let modelStatus = $state({ encoder_loaded: false, @@ -121,6 +127,7 @@ let reembedConfig = $state<{ idle_reembed_gpu: boolean; gpu_precision: string; idle_reembed_throttle_ms: number; + max_resident_memory_percent: number; batch_size: number; batch_delay_ms: number; auto_labels: boolean; @@ -132,6 +139,7 @@ let reembedConfig = $state<{ idle_reembed_gpu: true, gpu_precision: "f16", idle_reembed_throttle_ms: 10, + max_resident_memory_percent: 85, batch_size: 10, batch_delay_ms: 50, auto_labels: false, @@ -867,6 +875,16 @@ onDestroy(() => { {/if}
+ {:else if ir.memory_throttled && reembedConfig.idle_reembed_enabled && reembedEstimate.missing > 0} +
+ + + {t("model.idleReembedMemoryThrottled", { + pct: String(ir.memory_percent), + limit: String(reembedConfig.max_resident_memory_percent), + })} + +
{:else if reembedConfig.idle_reembed_enabled && ir.delay_secs > 0 && ir.idle_secs < ir.delay_secs && reembedEstimate.missing > 0}
@@ -1041,6 +1059,30 @@ onDestroy(() => {
+ +
+
+ {t("model.maxResidentMemory")} + {t("model.maxResidentMemoryDesc")} +
+
+ { reembedConfig.max_resident_memory_percent = Number((e.target as HTMLInputElement).value); }} + onchange={saveReembedConfig} + class="w-32 accent-blue-500" /> + + {reembedConfig.max_resident_memory_percent >= 100 + ? t("model.maxResidentMemoryDisabled") + : `${reembedConfig.max_resident_memory_percent}%`} + +
+
{/if} diff --git a/src/lib/settings/EmbeddingsTab.svelte b/src/lib/settings/EmbeddingsTab.svelte index 3855d0e3..eeff618c 100644 --- a/src/lib/settings/EmbeddingsTab.svelte +++ b/src/lib/settings/EmbeddingsTab.svelte @@ -23,12 +23,80 @@ interface ModelInfo { // ── State ────────────────────────────────────────────────────────────────── let models = $state([]); let currentCode = $state(""); +let currentBackend = $state<"fastembed" | "rlx">("fastembed"); +let rlxDevice = $state("metal"); +let rlxMaxSeq = $state(512); let staleCount = $state(0); let saving = $state(false); let reembedding = $state(false); let progress = $state<{ done: number; total: number; status?: string } | null>(null); let unlistenReembed: (() => void) | null = null; +// ── Label index backend ──────────────────────────────────────────────────── +type LabelIndexBackend = "hnsw" | "turboquant"; + +interface LabelIndexCounts { + text_nodes: number; + context_nodes: number; + eeg_nodes: number; +} + +interface LabelIndexFootprint { + text_bytes: number; + context_bytes: number; + eeg_bytes: number; +} +interface LabelIndexMemory { + hnsw: LabelIndexFootprint; + turbovec: LabelIndexFootprint; + total_bytes: number; +} +interface LabelIndexStats { + preferred_backend?: LabelIndexBackend; + hnsw?: LabelIndexCounts; + turbovec?: LabelIndexCounts; + memory?: LabelIndexMemory; +} + +function fmtBytes(n: number): string { + if (!Number.isFinite(n) || n <= 0) return "0 B"; + const units = ["B", "KiB", "MiB", "GiB"]; + let i = 0; + let v = n; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +interface LabelIndexBenchmarkRow { + backend: LabelIndexBackend; + available: boolean; + elapsed_us: number; + results: Array<{ label_id: number; text: string; distance: number }>; +} + +interface LabelIndexBenchmarkComparison { + top_match: boolean; + overlap_count: number; + overlap_ratio: number; + avg_distance_delta: number; + max_distance_delta: number; + close: boolean; + min_overlap_ratio: number; + max_allowed_distance_delta: number; +} + +let labelIndexBackend = $state("hnsw"); +let labelIndexSaving = $state(false); +let labelIndexRebuilding = $state(false); +let labelIndexStats = $state(null); +let benchmarkQuery = $state(""); +let benchmarking = $state(false); +let benchmarkResults = $state([]); +let benchmarkComparison = $state(null); + // ── Reembed config ──────────────────────────────────────────────────────── interface ReembedConfig { auto_labels: boolean; @@ -80,7 +148,87 @@ async function saveReembedConfig() { } } +async function loadLabelIndexBackend() { + try { + const r = await daemonInvoke<{ backend?: string }>("get_label_index_backend"); + labelIndexBackend = r.backend === "turboquant" ? "turboquant" : "hnsw"; + } catch (_) {} + await loadLabelIndexStats(); +} + +async function loadLabelIndexStats() { + try { + labelIndexStats = await daemonInvoke("get_label_index_stats"); + } catch (_) { + labelIndexStats = null; + } +} + +async function saveLabelIndexBackend() { + labelIndexSaving = true; + try { + const r = await daemonInvoke<{ ok: boolean; backend?: string; error?: string }>("set_label_index_backend", { + backend: labelIndexBackend, + }); + if (r.ok) { + labelIndexBackend = r.backend === "turboquant" ? "turboquant" : "hnsw"; + addToast("success", t("embeddings.indexBackendApplied"), t(`embeddings.indexBackend.${labelIndexBackend}`), 2500); + await loadLabelIndexStats(); + } else { + addToast("warning", t("embeddings.indexBackendFailed"), r.error ?? "Unknown error", 5000); + } + } catch (e) { + addToast("warning", t("embeddings.indexBackendFailed"), String(e), 5000); + } finally { + labelIndexSaving = false; + } +} + +async function rebuildLabelIndex() { + labelIndexRebuilding = true; + try { + await daemonInvoke("rebuild_label_index"); + await loadLabelIndexStats(); + addToast("success", t("embeddings.indexRebuilt"), "", 2500); + } catch (e) { + addToast("warning", t("embeddings.indexRebuildFailed"), String(e), 5000); + } finally { + labelIndexRebuilding = false; + } +} + +async function runIndexBenchmark() { + if (!benchmarkQuery.trim()) return; + benchmarking = true; + benchmarkResults = []; + benchmarkComparison = null; + try { + const r = await daemonInvoke<{ + ok: boolean; + benchmarks?: LabelIndexBenchmarkRow[]; + comparison?: LabelIndexBenchmarkComparison | null; + error?: string; + }>("benchmark_label_index", { + query: benchmarkQuery, + k: 5, + ef: 64, + mode: "text", + }); + if (r.ok) { + benchmarkResults = r.benchmarks ?? []; + benchmarkComparison = r.comparison ?? null; + } else { + addToast("warning", t("embeddings.indexBenchmarkFailed"), r.error ?? "Unknown error", 5000); + } + } catch (e) { + addToast("warning", t("embeddings.indexBenchmarkFailed"), String(e), 5000); + } finally { + benchmarking = false; + } +} + const activeModel = $derived(models.find((m) => m.code === currentCode) ?? null); +const supportsRlx = $derived(!currentCode.endsWith("-Q")); // Group models by family for the dropdown const grouped = $derived.by(() => { @@ -147,8 +295,16 @@ async function load() { models = knownModels; } try { - const r = await daemonInvoke<{ model: string }>("get_embedding_model"); + const r = await daemonInvoke<{ + model: string; + backend?: string; + rlx_device?: string; + rlx_max_seq?: number; + }>("get_embedding_model"); currentCode = r.model || models[0]?.code || ""; + currentBackend = r.backend === "rlx" ? "rlx" : "fastembed"; + rlxDevice = r.rlx_device || rlxDevice; + rlxMaxSeq = r.rlx_max_seq || rlxMaxSeq; } catch { currentCode = models[0]?.code ?? ""; } @@ -164,10 +320,24 @@ async function load() { async function applyModel() { saving = true; try { - const r = await daemonInvoke<{ ok: boolean; model?: string; error?: string }>("set_embedding_model", { + const backend = supportsRlx ? currentBackend : "fastembed"; + const r = await daemonInvoke<{ + ok: boolean; + model?: string; + backend?: string; + rlx_device?: string; + rlx_max_seq?: number; + error?: string; + }>("set_embedding_model", { model: currentCode, + backend, + rlx_device: rlxDevice, + rlx_max_seq: rlxMaxSeq, }); if (r.ok) { + currentBackend = r.backend === "rlx" ? "rlx" : "fastembed"; + rlxDevice = r.rlx_device || rlxDevice; + rlxMaxSeq = r.rlx_max_seq || rlxMaxSeq; addToast("success", t("embeddings.modelApplied"), currentCode, 3000); staleCount = 0; } else { @@ -189,7 +359,7 @@ async function reembed() { } onMount(async () => { - await Promise.all([load(), loadReembedConfig(), loadWatchdogConfig()]); + await Promise.all([load(), loadReembedConfig(), loadWatchdogConfig(), loadLabelIndexBackend()]); unlistenReembed = onDaemonEvent("reembed-progress", (ev) => { const p = ev.payload as { done?: number; total?: number; status?: string }; progress = { done: p.done ?? 0, total: p.total ?? 0, status: p.status }; @@ -207,6 +377,19 @@ onDestroy(() => unlistenReembed?.()); function dimColor(_dim: number) { return "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"; } + +function fmtMs(us: number) { + if (!Number.isFinite(us)) return "—"; + return `${(us / 1000).toFixed(us < 10_000 ? 2 : 1)} ms`; +} + +function countsFor(backend: "hnsw" | "turbovec") { + return labelIndexStats?.[backend] ?? { text_nodes: 0, context_nodes: 0, eeg_nodes: 0 }; +} + +function pct(v: number) { + return `${Math.round(v * 100)}%`; +}
@@ -261,6 +444,228 @@ function dimColor(_dim: number) { {/if} + +
+
+ + {t("embeddings.backend")} + +

+ {t("embeddings.backendDesc")} +

+
+ +
+ + +
+ + {#if !supportsRlx} +

+ {t("embeddings.rlxQuantizedUnsupported")} +

+ {/if} + + {#if currentBackend === "rlx" && supportsRlx} +
+
+ + +
+
+ + +
+
+

+ {t("embeddings.rlxHint")} +

+ {/if} +
+ + +
+
+
+ + {t("embeddings.indexBackend")} + +

+ {t("embeddings.indexBackendDesc")} +

+
+ +
+ +
+ + +
+ +
+ + {t("embeddings.indexCurrent", { backend: t(`embeddings.indexBackend.${labelIndexBackend}`) })} + + +
+ + {#if labelIndexStats?.memory} + {@const mem = labelIndexStats.memory} +
+ + {t("embeddings.indexMemory")} + + + {t("embeddings.indexMemoryRow", { + backend: t("embeddings.indexBackend.hnsw"), + total: fmtBytes(mem.hnsw.text_bytes + mem.hnsw.context_bytes + mem.hnsw.eeg_bytes), + text: fmtBytes(mem.hnsw.text_bytes), + context: fmtBytes(mem.hnsw.context_bytes), + eeg: fmtBytes(mem.hnsw.eeg_bytes), + })} + + + {t("embeddings.indexMemoryRow", { + backend: t("embeddings.indexBackend.turboquant"), + total: fmtBytes(mem.turbovec.text_bytes + mem.turbovec.context_bytes + mem.turbovec.eeg_bytes), + text: fmtBytes(mem.turbovec.text_bytes), + context: fmtBytes(mem.turbovec.context_bytes), + eeg: fmtBytes(mem.turbovec.eeg_bytes), + })} + + + {t("embeddings.indexMemoryTotal", { total: fmtBytes(mem.total_bytes) })} + +
+ {/if} + + + +
+
+ { + if (e.key === "Enter") runIndexBenchmark(); + }} + class="min-w-0 flex-1 rounded-md border border-border dark:border-white/[0.08] + bg-surface-1 px-2.5 py-1.5 text-ui-md text-foreground + focus:outline-none focus:ring-1 focus:ring-ring/50" /> + +
+ + {#if benchmarkResults.length > 0} + {#if benchmarkComparison} +
+
+ + {benchmarkComparison.close ? t("embeddings.indexBenchmarkClose") : t("embeddings.indexBenchmarkDiverged")} + + + {benchmarkComparison.overlap_count}/5 · {pct(benchmarkComparison.overlap_ratio)} + +
+

+ {t("embeddings.indexBenchmarkDelta", { + avg: benchmarkComparison.avg_distance_delta.toFixed(4), + max: benchmarkComparison.max_distance_delta.toFixed(4), + })} +

+
+ {/if} + +
+ {#each benchmarkResults as row} +
+
+ + {t(`embeddings.indexBackend.${row.backend}`)} + + {fmtMs(row.elapsed_us)} +
+ {#if row.available} +

+ {row.results[0]?.text ?? t("embeddings.indexBenchmarkNoResults")} +

+ {#if row.results[0]} +

+ distance {row.results[0].distance.toFixed(3)} +

+ {/if} + {:else} +

+ {t("embeddings.indexUnavailable")} +

+ {/if} +
+ {/each} +
+ {/if} +
+
+
diff --git a/src/lib/settings/GoalsTab.svelte b/src/lib/settings/GoalsTab.svelte index d95d434f..1824e19a 100644 --- a/src/lib/settings/GoalsTab.svelte +++ b/src/lib/settings/GoalsTab.svelte @@ -403,7 +403,7 @@ const streak = $derived.by(() => { bg-clip-text text-transparent"> {dailyGoalMin}m - + {goalHours >= 1 ? `${goalHours.toFixed(1)} hours` : `${dailyGoalMin} minutes`} / day {#if streak > 0} @@ -427,7 +427,7 @@ const streak = $derived.by(() => { bind:value={dailyGoalMin} oninput={save} class="w-full accent-violet-500 h-2" /> -
+
5m 1h 2h @@ -469,7 +469,7 @@ const streak = $derived.by(() => {
- + {fmtMins(dailyGoalMin)}
@@ -509,7 +509,7 @@ const streak = $derived.by(() => {
-
+
{chartDays[0]?.label ?? ""} {chartDays[Math.floor(chartDays.length / 2)]?.label ?? ""} {t("goals.today")} @@ -636,7 +636,7 @@ const streak = $derived.by(() => { value={dndConfig.focus_threshold} oninput={(e) => setDndThreshold(Number((e.currentTarget as HTMLInputElement).value))} class="w-full accent-violet-500 h-2" /> -
+
10 40 60 @@ -854,7 +854,7 @@ const streak = $derived.by(() => {
-
{#if scoreAbove} 0s @@ -918,7 +918,7 @@ const streak = $derived.by(() => {
-
{#if isCounting} 0s diff --git a/src/lib/settings/LlmTab.svelte b/src/lib/settings/LlmTab.svelte index f32d23d4..9aaa4290 100644 --- a/src/lib/settings/LlmTab.svelte +++ b/src/lib/settings/LlmTab.svelte @@ -17,6 +17,7 @@ import { onDaemonEvent } from "$lib/daemon/ws"; import LlmHfSearchSection from "$lib/llm/LlmHfSearchSection.svelte"; import LlmInferenceSection from "$lib/llm/LlmInferenceSection.svelte"; import LlmModelPickerSection from "$lib/llm/LlmModelPickerSection.svelte"; +import LlmMtpSection from "$lib/llm/LlmMtpSection.svelte"; import LlmServerLogSection from "$lib/llm/LlmServerLogSection.svelte"; import LlmServerSection from "$lib/llm/LlmServerSection.svelte"; import type { LlmCatalog } from "$lib/llm/llm-helpers"; @@ -57,6 +58,7 @@ interface LlmConfig { enabled: boolean; autostart: boolean; model_path: string | null; + runtime: "llama_cpp" | "rlx"; n_gpu_layers: number; ctx_size: number | null; n_batch: number | null; @@ -76,6 +78,7 @@ interface LlmConfig { cache_type_k: string; cache_type_v: string; attn_rot_disabled: boolean; + mtp_draft_count: number; } interface ModelHardwareFit { @@ -98,6 +101,7 @@ let config = $state({ enabled: false, autostart: false, model_path: null, + runtime: "llama_cpp", n_gpu_layers: 4294967295, ctx_size: null, n_batch: null, @@ -132,6 +136,7 @@ let config = $state({ cache_type_k: "f16", cache_type_v: "f16", attn_rot_disabled: false, + mtp_draft_count: 0, }); let configSaving = $state(false); @@ -405,6 +410,18 @@ onDestroy(() => { onSelectMmproj={selectMmproj} /> + + + +{#if activeEntry?.mtp} + { config = { ...config, mtp_draft_count: val }; await saveConfig(); }} +/> +{/if} + @@ -437,6 +454,7 @@ onDestroy(() => { onSetNUbatch={async (val) => { config = { ...config, n_ubatch: val }; await saveConfig(); }} onToggleFlashAttention={async () => { config = { ...config, flash_attention: !config.flash_attention }; await saveConfig(); }} onToggleOffloadKqv={async () => { config = { ...config, offload_kqv: !config.offload_kqv }; await saveConfig(); }} + onSetRuntime={async (val) => { config = { ...config, runtime: val }; await saveConfig(); }} /> diff --git a/src/lib/settings/LslTab.svelte b/src/lib/settings/LslTab.svelte index a6731dba..ffaed5d9 100644 --- a/src/lib/settings/LslTab.svelte +++ b/src/lib/settings/LslTab.svelte @@ -626,7 +626,7 @@ onDestroy(() => {
{t("lsl.recording")} @@ -891,7 +891,7 @@ onDestroy(() => { > {#if stream.paired} {t("lsl.paired")} diff --git a/src/lib/settings/SettingsTab.svelte b/src/lib/settings/SettingsTab.svelte index f9e85813..3886c4d4 100644 --- a/src/lib/settings/SettingsTab.svelte +++ b/src/lib/settings/SettingsTab.svelte @@ -11,6 +11,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { relaunch } from "@tauri-apps/plugin-process"; import { onDestroy, onMount } from "svelte"; +import DaemonActivityPanel from "$lib/components/DaemonActivityPanel.svelte"; import { Button } from "$lib/components/ui/button"; import { CardContent } from "$lib/components/ui/card"; import { SectionHeader } from "$lib/components/ui/section-header"; @@ -1019,3 +1020,5 @@ onDestroy(() => {
+ + diff --git a/src/lib/settings/SleepTab.svelte b/src/lib/settings/SleepTab.svelte index 53a61296..6d89bef6 100644 --- a/src/lib/settings/SleepTab.svelte +++ b/src/lib/settings/SleepTab.svelte @@ -163,7 +163,7 @@ function arcPath(startAngle: number, endAngle: number, r: number): string { bg-clip-text text-transparent"> {durationLabel(duration.total)}
- + {config.bedtime} — {config.wake_time} diff --git a/src/lib/settings/TerminalSessionsCard.svelte b/src/lib/settings/TerminalSessionsCard.svelte index 32ca6331..709f1ec3 100644 --- a/src/lib/settings/TerminalSessionsCard.svelte +++ b/src/lib/settings/TerminalSessionsCard.svelte @@ -424,11 +424,11 @@ function toggleDay(key: string) { class="flex w-full items-baseline gap-2 rounded px-1 py-0.5 text-left hover:bg-muted/30" onclick={() => toggleDay(bucket.key)} > - {collapsed ? "▸" : "▾"} + {collapsed ? "▸" : "▾"} {bucket.label} - + {bucket.rows.length} session{bucket.rows.length === 1 ? "" : "s"} @@ -479,11 +479,11 @@ function toggleDay(key: string) { {/if} {#if isLive} - + live {:else if isLegacy} - + legacy {/if} diff --git a/src/lib/settings/TerminalTab.svelte b/src/lib/settings/TerminalTab.svelte index 4cf7ff9c..344417e4 100644 --- a/src/lib/settings/TerminalTab.svelte +++ b/src/lib/settings/TerminalTab.svelte @@ -297,7 +297,7 @@ function categoryColor(cat: string): string { ! {/if} - {cmd.category} + {cmd.category} {cmd.command} {/each} diff --git a/src/lib/settings/TlxForm.svelte b/src/lib/settings/TlxForm.svelte index 11372f8e..5f9349f4 100644 --- a/src/lib/settings/TlxForm.svelte +++ b/src/lib/settings/TlxForm.svelte @@ -153,7 +153,7 @@ async function submit() { oninput={(e) => scale.set(Number((e.target as HTMLInputElement).value))} class="mt-1 w-full" /> -
+
{scale.inverted ? t("validation.tlx.failure") : t("validation.tlx.low")} diff --git a/src/lib/settings/TokensTab.svelte b/src/lib/settings/TokensTab.svelte index 868a256d..0e2d7096 100644 --- a/src/lib/settings/TokensTab.svelte +++ b/src/lib/settings/TokensTab.svelte @@ -159,8 +159,8 @@ onMount(refresh);
{t("tokens.defaultToken")} - admin - {t("tokens.expiryNever")} + admin + {t("tokens.expiryNever")}
{:else}
@@ -435,6 +482,7 @@ onDestroy(() => { {/if} + {#if !(phase === "idle" && available && !autoUpdateEnabled)} + {/if}
@@ -519,6 +568,52 @@ onDestroy(() => { + + + + + + {#if autoUpdateError} +
+ {autoUpdateError} +
+ {/if} +
+
+ diff --git a/src/lib/umap/UmapViewer3D.svelte b/src/lib/umap/UmapViewer3D.svelte index a431a9c7..12bae504 100644 --- a/src/lib/umap/UmapViewer3D.svelte +++ b/src/lib/umap/UmapViewer3D.svelte @@ -1496,7 +1496,7 @@ onDestroy(() => {
{#each traceTimeTicks as tick, i} {@const align = i === 0 ? 'left-0' : i === traceTimeTicks.length - 1 ? 'right-0' : '-translate-x-1/2'} - 0 && i < traceTimeTicks.length - 1 ? `left:${tick.pct}%` : ''}> {tick.label} @@ -1535,11 +1535,11 @@ onDestroy(() => {
- + {t("umap.sessionA")} - + {t("umap.sessionB")} @@ -1550,7 +1550,7 @@ onDestroy(() => { {#each groups as group}
- {group.label}
@@ -1584,11 +1584,11 @@ onDestroy(() => { text-slate-700 dark:text-white/75">{entry.label}
{#if entry.inA} - A {/if} {#if entry.inB} - B {/if}
@@ -1596,7 +1596,7 @@ onDestroy(() => {
{#each entry.timestamps.slice(0, 6) as ts} - @@ -1604,7 +1604,7 @@ onDestroy(() => { {/each} {#if entry.timestamps.length > 6} - + +{entry.timestamps.length - 6} {/if} @@ -1628,7 +1628,7 @@ onDestroy(() => { -
+
{#if selectedLabel && !animating} {#if proximateLabels.length > 0} @@ -1753,17 +1753,17 @@ onDestroy(() => { {#if timeGradient && gradientRange} {@const span = gradientRange.maxUtc - gradientRange.minUtc}
- + Session {timeGradient} · time →
- + {fmtGradientTs(gradientRange.minUtc, span)}
- + {fmtGradientTs(gradientRange.maxUtc, span)}
@@ -1872,7 +1872,7 @@ onDestroy(() => { {traceActive ? t("umap.traceStop") : t("umap.trace")}
{#if traceActive && traceTotal > 0} - {traceProgress}/{traceTotal} + {traceProgress}/{traceTotal} {/if}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b27a5506..1aeb9686 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -329,6 +329,7 @@ let dfaScore = $state(0); // DFA Exponent let seScore = $state(0); // Sample Entropy let pacScore = $state(0); // PAC (θ–γ) let latScore = $state(0); // Laterality Index +let echtScore = $state(0); // ECHT alpha rhythmicity let hrScore = $state(0); // Heart Rate (bpm) let rmssdScore = $state(0); // RMSSD (ms) let sdnnScore = $state(0); // SDNN (ms) @@ -427,6 +428,7 @@ function updateScores(snap: BandSnapshot) { if (snap.sample_entropy !== undefined) seScore = seScore + SCORE_TAU * (snap.sample_entropy - seScore); if (snap.pac_theta_gamma !== undefined) pacScore = pacScore + SCORE_TAU * (snap.pac_theta_gamma - pacScore); if (snap.laterality_index !== undefined) latScore = latScore + SCORE_TAU * (snap.laterality_index - latScore); + if (snap.echt !== undefined) echtScore = echtScore + SCORE_TAU * (snap.echt - echtScore); // PPG-derived if (snap.hr !== undefined && snap.hr > 0) hrScore = hrScore + SCORE_TAU * (snap.hr - hrScore); if (snap.rmssd !== undefined && snap.rmssd > 0) rmssdScore = rmssdScore + SCORE_TAU * (snap.rmssd - rmssdScore); @@ -1737,7 +1739,7 @@ useWindowTitle("window.title.main"); rounded bg-foreground/[0.06] dark:bg-white/[0.06] text-muted-foreground/60">{sourceLabel}
{/if} {#if hasSecondary} - {t("dashboard.primary")} +{secondarySessions.length} {/if} @@ -1823,7 +1825,7 @@ useWindowTitle("window.title.main"); rounded bg-foreground/[0.06] dark:bg-white/[0.06] text-muted-foreground/60">{sourceLabel} {/if} {#if hasSecondary} - {t("dashboard.primary")} {/if}

@@ -2137,7 +2139,7 @@ useWindowTitle("window.title.main"); {(status.battery ?? 0).toFixed(0)}% {#if status.temperature_raw > 0} - + 🌡 {status.temperature_raw} {/if} @@ -2237,27 +2239,27 @@ useWindowTitle("window.title.main");
fNIRS: {fnirsLabels.join(" · ")}
-
Oxy
+
Oxy
{(status.fnirs_oxygenation_pct ?? 0).toFixed(1)}%
-
Workload
+
Workload
{(status.fnirs_workload ?? 0).toFixed(1)}
-
Lat
+
Lat
{(status.fnirs_lateralization ?? 0).toFixed(1)}
-
ΔHbO
+
ΔHbO
{((((status.fnirs_hbo_left ?? 0) + (status.fnirs_hbo_right ?? 0)) / 2)).toFixed(3)}
-
ΔHbR
+
ΔHbR
{((((status.fnirs_hbr_left ?? 0) + (status.fnirs_hbr_right ?? 0)) / 2)).toFixed(3)}
-
Conn
+
Conn
{(status.fnirs_connectivity ?? 0).toFixed(3)}
@@ -2292,6 +2294,7 @@ useWindowTitle("window.title.main"); mood={moodScore} bps={bpsScore} snr={snrScore} coherence={coherenceScore} mu={muScore} tbr={tbrScore} sef95={sef95Score} sc={scScore} ha={haScore} hm={hmScore} hc={hcScore} pe={peScore} hfd={hfdScore} dfa={dfaScore} se={seScore} pac={pacScore} lat={latScore} + echt={echtScore} headache={headacheScore} migraine={migraineScore} showMu={status.has_central_electrodes} />
@@ -2372,7 +2375,7 @@ useWindowTitle("window.title.main"); {t("dashboard.imu")} {#if hasImuData} - + {/if} {#if imuExpanded} @@ -2399,7 +2402,7 @@ useWindowTitle("window.title.main"); group-hover:text-foreground transition-colors"> {t("dashboard.eegChannels")} - + {#if eegChExpanded}
4 && chLabels.length <= 8} class:grid-cols-4={chLabels.length > 8}> @@ -2444,7 +2447,7 @@ useWindowTitle("window.title.main"); ] as [label, val]}
- {label} + {label} {val}
{/each} @@ -2656,7 +2659,7 @@ useWindowTitle("window.title.main"); {sess.device_name} - {sess.device_kind === "lsl" ? "LSL" : sess.device_kind === "lsl-iroh" ? "iroh" : sess.device_kind.toUpperCase()} diff --git a/src/routes/calibration/+page.svelte b/src/routes/calibration/+page.svelte index 3d8ecc4f..2b85c00b 100644 --- a/src/routes/calibration/+page.svelte +++ b/src/routes/calibration/+page.svelte @@ -481,7 +481,7 @@ useWindowTitle("window.title.calibration"); : 'text-muted-foreground border-border dark:border-white/[0.06] hover:text-foreground hover:border-foreground/30'}" > {etab.label} - {etab.count} + {etab.count} {/each} {#if !museConnected} @@ -506,11 +506,11 @@ useWindowTitle("window.title.calibration");
{name} - {elecQualityText(label)} - {MUSE_POSITIONS[idx]} + {MUSE_POSITIONS[idx]}
{/each}
@@ -530,7 +530,7 @@ useWindowTitle("window.title.calibration"); style="background:{elecQualityColor(label)}">
{name} - + {elecQualityText(label)}
diff --git a/src/routes/compare/+page.svelte b/src/routes/compare/+page.svelte index 8cce04d9..e719cbfd 100644 --- a/src/routes/compare/+page.svelte +++ b/src/routes/compare/+page.svelte @@ -1067,17 +1067,17 @@ useWindowTitle("window.title.compare"); {#if emb} {#if pct >= 90} - + {:else if pct > 0} - {pct}% {:else} - @@ -1150,14 +1150,14 @@ useWindowTitle("window.title.compare");
{#if dayStr} - {fromUnix(anchor + 43200).toLocaleDateString("default",{weekday:"short",month:"short",day:"numeric"})} {/if} {#if day2Str} - {fromUnix(day2Utc + 43200).toLocaleDateString("default",{weekday:"short",month:"short",day:"numeric"})} @@ -1174,7 +1174,7 @@ useWindowTitle("window.title.compare"); background:{isMidnight?'rgba(148,163,184,0.5)':'rgba(148,163,184,0.2)'}"> {#if hOff % 6 === 0} - {String(localH).padStart(2,"0")} @@ -1211,7 +1211,7 @@ useWindowTitle("window.title.compare"); ring-0 hover:ring-2 hover:ring-white/60 hover:ring-offset-0" style="left:{lp}%; width:{wp}%; background:{clr}; opacity:0.72; z-index:2"> {#if wp > 5} - {dur} + {dur} {/if} {/each} @@ -1225,9 +1225,9 @@ useWindowTitle("window.title.compare");
{#if rw > 8} - {utcToTimeStr(rangeStart)} - {utcToTimeStr(rangeEnd)} {/if}
@@ -1510,7 +1510,7 @@ useWindowTitle("window.title.compare"); bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-ui-xs font-medium"> {d.label} - + {d.pctChange > 0 ? "+" : ""}{d.pctChange.toFixed(0)}% @@ -1525,14 +1525,14 @@ useWindowTitle("window.title.compare"); bg-red-500/10 text-red-500 dark:text-red-400 text-ui-xs font-medium"> {d.label} - + {d.pctChange > 0 ? "+" : ""}{d.pctChange.toFixed(0)}% {/each}
{/if} -

+

Comparing session B vs A. Changes >3% shown.

@@ -1575,7 +1575,7 @@ useWindowTitle("window.title.compare"); {/each}
-
+
{t("dashboard.faaWithdrawal")} {t("dashboard.faaFormula")} {t("dashboard.faaApproach")} @@ -1662,7 +1662,7 @@ useWindowTitle("window.title.compare"); class="w-full block" style="height:{HEATMAP_ROW_H * 5}px; display:block">
- + {t("compare.heatmapRowNorm")} · {tsA.length} epochs
@@ -1681,7 +1681,7 @@ useWindowTitle("window.title.compare"); class="w-full block" style="height:{HEATMAP_ROW_H * 5}px; display:block">
- + {t("compare.heatmapRowNorm")} · {tsB.length} epochs
@@ -1697,7 +1697,7 @@ useWindowTitle("window.title.compare"); class="w-full block" style="height:{HEATMAP_ROW_H * 5 + 12}px; display:block">
- + {t("compare.heatmapDiffLegend")} · time-proportionally aligned
@@ -1732,7 +1732,7 @@ useWindowTitle("window.title.compare"); class="w-full block" style="height:{HEATMAP_ROW_H * 5}px; display:block"> - + {t("compare.heatmapRowNorm")} @@ -1917,7 +1917,7 @@ useWindowTitle("window.title.compare");
Brain Nebula™ - {t("compare.umap")} + {t("compare.umap")}
@@ -1973,7 +1973,7 @@ useWindowTitle("window.title.compare"); style="width:{reembedPct}%">
{#if eta} - + {eta.rate}/sec · ~{fmtDuration(eta.etaSecs)} left {/if} @@ -1988,7 +1988,7 @@ useWindowTitle("window.title.compare");
Brain Nebula™ - {t("compare.umap")} + {t("compare.umap")} {#if umapLoading} {#if umapQueuePosition !== null && umapQueuePosition > 0} @@ -2024,7 +2024,7 @@ useWindowTitle("window.title.compare");
- + {fmtSecs(umapElapsed)} elapsed @@ -2032,7 +2032,7 @@ useWindowTitle("window.title.compare"); {:else} - + computing 3D projection · {fmtSecs(umapElapsed)} elapsed {#if umapProgress && umapProgress.total_epochs > 0} @@ -2047,12 +2047,12 @@ useWindowTitle("window.title.compare");
- + {pct}% {#if remSecs !== null} - + epoch {umapProgress.epoch}/{umapProgress.total_epochs} · {umapProgress.epoch_ms.toFixed(0)}ms/ep · ~{fmtSecs(remSecs)} left @@ -2069,7 +2069,7 @@ useWindowTitle("window.title.compare");
- + ~{fmtSecs(Math.max(0, estSecs - umapElapsed))} @@ -2078,14 +2078,14 @@ useWindowTitle("window.title.compare"); {/if} {:else if umapResult} - + {umapResult.n_a} + {umapResult.n_b} {t("compare.umapPoints")} · dim={umapResult.dim} · 3D {#if umapComputeMs != null} · {umapComputeMs < 1000 ? `${umapComputeMs}ms` : `${(umapComputeMs / 1000).toFixed(1)}s`} compute {/if} {#if umapAnalysis} - = 2 ? 'bg-emerald-500/10 text-emerald-500' : umapAnalysis.separationScore >= 1 ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400' : 'bg-red-500/10 text-red-400'}"> @@ -2132,7 +2132,7 @@ useWindowTitle("window.title.compare"); {/if} -
+
A ({(umapResult ?? umapPlaceholder)?.n_a ?? 0}) @@ -2183,23 +2183,23 @@ useWindowTitle("window.title.compare"); {item.label}
- Efficiency + Efficiency {item.sa.efficiency.toFixed(0)}%
- Onset + Onset {item.sa.onsetLatencyMin.toFixed(0)}m
{#if item.sa.remLatencyMin >= 0}
- → REM + → REM {item.sa.remLatencyMin.toFixed(0)}m
{/if}
- Awakenings + Awakenings {item.sa.awakenings}
diff --git a/src/routes/help/+page.svelte b/src/routes/help/+page.svelte index 3e392b3a..ff892eef 100644 --- a/src/routes/help/+page.svelte +++ b/src/routes/help/+page.svelte @@ -192,6 +192,11 @@ let splitRoot: HTMLDivElement | null = null; let navEl: HTMLElement | null = null; let navWidth = $state(176); let resizingNav = false; +// Tailwind `sm` breakpoint = 640px. Below that the sidebar collapses +// to icon-only width via the `w-12` class; above, `navWidth` controls +// the (resizable) width inline. +let windowWidth = $state(0); +const navStyle = $derived(windowWidth >= 640 ? `width:${navWidth}px;min-width:max-content` : ""); const NAV_WIDTH_MIN = 140; const NAV_WIDTH_MAX = 480; @@ -287,16 +292,22 @@ onDestroy(() => { useWindowTitle("window.title.help"); + +
- -