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/workflows/ci.yml b/.github/workflows/ci.yml index d02c7d62..8e686bd7 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 @@ -470,6 +473,9 @@ 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 @@ -610,6 +616,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 @@ -742,6 +752,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 @@ -851,6 +864,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 +952,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 @@ -1022,6 +1041,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..1d085c49 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 @@ -174,32 +177,39 @@ jobs: # 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 - + 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 $? + # 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 + if [ ! -f "$DAEMON_BIN" ]; then + echo "::error::Daemon binary missing after successful cargo build: $DAEMON_BIN" + return 1 fi + 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/" || return $? + install_name_tool -change @rpath/$LIB @executable_path/../Frameworks/$LIB "$DAEMON_BIN" || return $? + fi + done } if ! run_cmd; then if [[ -n "${LLAMA_PREBUILT_DIR:-}" ]]; then diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index ace22bb4..ff86ea25 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 @@ -225,6 +228,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 @@ -546,8 +553,11 @@ 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 @@ -906,7 +916,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..a5cfc7ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4626,3 +4626,1035 @@ 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 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..aea9d87d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" dependencies = [ - "equator", + "equator 0.4.2", ] [[package]] @@ -249,13 +249,22 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "ar_archive_writer" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object", + "object 0.37.3", ] [[package]] @@ -267,6 +276,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 +310,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 +320,7 @@ dependencies = [ "arrow-schema", "chrono", "half", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "num-complex", "num-integer", "num-traits", @@ -310,9 +328,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 +340,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,9 +353,9 @@ 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", @@ -349,15 +367,15 @@ dependencies = [ [[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 +394,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,15 +605,6 @@ 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" @@ -656,9 +671,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", ] @@ -760,11 +775,17 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link 0.2.1", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base32" version = "0.5.1" @@ -908,9 +929,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", @@ -920,6 +941,16 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "blas-src" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95e83dc868db96e69795c0213143095f03de9dd3252f205d4ac716e4076a7e0" +dependencies = [ + "accelerate-src", + "openblas-src", +] + [[package]] name = "blas-src" version = "0.14.0" @@ -947,9 +978,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", ] @@ -1118,6 +1149,15 @@ dependencies = [ "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" @@ -1136,7 +1176,7 @@ dependencies = [ "async-trait", "bitflags 2.11.1", "bluez-async", - "dashmap 6.1.0", + "dashmap 6.2.1", "dbus", "futures", "jni 0.19.0", @@ -1164,7 +1204,7 @@ dependencies = [ "async-trait", "bitflags 2.11.1", "bluez-async", - "dashmap 6.1.0", + "dashmap 6.2.1", "dbus", "futures", "jni 0.19.0", @@ -1245,7 +1285,7 @@ dependencies = [ "hashbrown 0.16.1", "num-traits", "rand 0.9.4", - "rand_distr", + "rand_distr 0.5.1", "serde", "thiserror 2.0.18", ] @@ -1429,7 +1469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96be578991cecef163e41a73bf985d8d7eb7fb8ef7bececf8d48523c481ecddf" dependencies = [ "atomic_float", - "blas-src", + "blas-src 0.14.0", "burn-autodiff", "burn-backend", "burn-ir", @@ -1706,7 +1746,7 @@ dependencies = [ "num-traits", "num_cpus", "rand 0.9.4", - "rand_distr", + "rand_distr 0.5.1", "rayon", "safetensors 0.7.0", "thiserror 2.0.18", @@ -1733,7 +1773,7 @@ dependencies = [ "objc2-foundation 0.3.2", "objc2-metal", "rand 0.9.4", - "rand_distr", + "rand_distr 0.5.1", "rayon", "safetensors 0.7.0", "thiserror 2.0.18", @@ -1862,9 +1902,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -1955,6 +1995,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +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" @@ -2052,6 +2098,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "cobs" version = "0.3.0" @@ -2072,6 +2124,12 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "coe-rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8f1e641542c07631228b1e0dc04b69ae3c1d58ef65d5691a439711d805c698" + [[package]] name = "cognionics" version = "0.0.1" @@ -2225,12 +2283,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "136d3e02915a2cea4d74caa8681e2d44b1c3254bdbf17d11d41d587ff858832c" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.8.0" @@ -2357,9 +2409,9 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16dd574a72a021b90c7656c474ea31d11a2f0366a8eff574186e761e0b9e3586" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ "bitflags 2.11.1", "libc", @@ -2428,9 +2480,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" @@ -2524,7 +2576,7 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.11.1", "crossterm_winapi", - "derive_more 2.1.1", + "derive_more", "document-features", "mio", "parking_lot", @@ -2561,9 +2613,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -2578,23 +2630,6 @@ dependencies = [ "phf 0.11.3", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - [[package]] name = "cssparser" version = "0.36.0" @@ -2641,14 +2676,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.117", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "ctr" version = "0.9.2" @@ -2665,10 +2706,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.31.2", + "nix 0.31.3", "windows-sys 0.61.2", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "cubecl" version = "0.9.0" @@ -2698,7 +2748,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "derive-new", - "derive_more 2.1.1", + "derive_more", "dirs", "embassy-futures", "embassy-time", @@ -2736,7 +2786,7 @@ dependencies = [ "cubecl-macros", "cubecl-runtime", "derive-new", - "derive_more 2.1.1", + "derive_more", "enumset", "float-ord", "half", @@ -2849,7 +2899,7 @@ dependencies = [ "cubecl-common", "cubecl-macros-internal", "derive-new", - "derive_more 2.1.1", + "derive_more", "enumset", "float-ord", "fnv", @@ -2920,7 +2970,7 @@ dependencies = [ "cubecl-common", "cubecl-ir", "derive-new", - "derive_more 2.1.1", + "derive_more", "dirs", "enumset", "foldhash 0.1.5", @@ -2990,7 +3040,7 @@ dependencies = [ "cubecl-runtime", "cubecl-spirv", "derive-new", - "derive_more 2.1.1", + "derive_more", "half", "hashbrown 0.15.5", "log", @@ -3114,16 +3164,16 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "5.0.0-pre.1" +version = "5.0.0-pre.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest 0.11.0-rc.10", + "digest 0.11.3", "fiat-crypto", - "rand_core 0.9.5", + "rand_core 0.10.1", "rustc_version", "serde", "subtle", @@ -3291,9 +3341,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -3311,9 +3361,35 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.10.0" +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 = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.117", +] + +[[package]] +name = "dbgf" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "e6ca96b45ca70b8045e0462f191bd209fcb3c3bfe8dbfb1257ada54c4dd59169" [[package]] name = "dbus" @@ -3452,19 +3528,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "derive_more" -version = "0.99.20" -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", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -3513,13 +3576,13 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.0-rc.10" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afa94b64bfc6549e6e4b5a3216f22593224174083da7a90db47e951c4fb31725" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.11.0", + "block-buffer 0.12.0", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -3596,17 +3659,6 @@ dependencies = [ "libloading 0.8.9", ] -[[package]] -name = "dlopen2" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" -dependencies = [ - "libc", - "once_cell", - "winapi", -] - [[package]] name = "dlopen2" version = "0.8.2" @@ -3646,12 +3698,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set 0.8.0", - "cssparser 0.36.0", + "cssparser", "foldhash 0.2.0", - "html5ever 0.38.0", + "html5ever", "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", + "selectors", + "tendril", ] [[package]] @@ -3724,6 +3776,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -3736,6 +3803,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dyn-stack" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6fa63092e3ca9f602f6500fddd05502412b748c4c4682938565b44eb9e0066" +dependencies = [ + "bytemuck", +] + [[package]] name = "dyn-stack" version = "0.13.2" @@ -3754,26 +3830,26 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" [[package]] name = "ed25519" -version = "3.0.0-rc.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8", - "serde", + "serdect", "signature", ] [[package]] name = "ed25519-dalek" -version = "3.0.0-pre.1" +version = "3.0.0-pre.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core 0.9.5", + "rand_core 0.10.1", "serde", - "sha2 0.11.0-rc.2", + "sha2 0.11.0", "signature", "subtle", "zeroize", @@ -3827,14 +3903,14 @@ dependencies = [ [[package]] name = "embed-resource" -version = "3.0.8" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -3976,9 +4052,9 @@ dependencies = [ [[package]] name = "enumset" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" dependencies = [ "enumset_derive", "serde", @@ -3986,9 +4062,9 @@ dependencies = [ [[package]] name = "enumset_derive" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -4019,13 +4095,33 @@ dependencies = [ "log", ] +[[package]] +name = "equator" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35da53b5a021d2484a7cc49b2ac7f2d840f8236a286f84202369bd338d761ea" +dependencies = [ + "equator-macro 0.2.1", +] + [[package]] name = "equator" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" dependencies = [ - "equator-macro", + "equator-macro 0.4.2", +] + +[[package]] +name = "equator-macro" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -4074,9 +4170,9 @@ checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" [[package]] name = "espeak-ng" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf53da2d1e3049cfff5ae289a05b456c753c15cbe0bff2b97251aef5a748da94" +checksum = "083cee3337773bfa7f1c38fe022dab392438ced57f3a64c883011952d13b83a1" dependencies = [ "bitflags 2.11.1", "espeak-ng-data-dict-ru", @@ -4182,6 +4278,50 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "faer" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2b19b8c3570ea226e507fe3dbae2aa9d7e0f16676abd35ea3adeeb9f90f7b5d" +dependencies = [ + "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 = "faer-entity" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "072f96f1bc8b2b30dfc26c086baeadb63aae08019b1ed84721809b9fd2006685" +dependencies = [ + "bytemuck", + "coe-rs", + "libm", + "num-complex", + "num-traits", + "pulp 0.18.22", + "reborrow", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -4217,9 +4357,9 @@ dependencies = [ [[package]] name = "fancy-regex" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" dependencies = [ "bit-set 0.8.0", "regex-automata", @@ -4258,18 +4398,6 @@ dependencies = [ "serde", ] -[[package]] -name = "fastbloom" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" -dependencies = [ - "getrandom 0.3.4", - "libm", - "rand 0.9.4", - "siphasher 1.0.2", -] - [[package]] name = "fastembed" version = "5.13.2" @@ -4297,23 +4425,9 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -4353,13 +4467,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -4441,7 +4554,7 @@ dependencies = [ "half", "num-traits", "rand 0.9.4", - "rand_distr", + "rand_distr 0.5.1", ] [[package]] @@ -4453,7 +4566,7 @@ dependencies = [ "half", "num-traits", "rand 0.9.4", - "rand_distr", + "rand_distr 0.5.1", ] [[package]] @@ -4555,16 +4668,6 @@ dependencies = [ "libc", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.32" @@ -4664,9 +4767,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" @@ -4823,7 +4926,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-c32 0.18.2", "gemm-c64 0.18.2", "gemm-common 0.18.2", @@ -4843,7 +4946,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-c32 0.19.0", "gemm-c64 0.19.0", "gemm-common 0.19.0", @@ -4863,7 +4966,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "num-complex", "num-traits", @@ -4878,7 +4981,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.19.0", "num-complex", "num-traits", @@ -4893,7 +4996,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "num-complex", "num-traits", @@ -4908,7 +5011,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.19.0", "num-complex", "num-traits", @@ -4924,7 +5027,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" dependencies = [ "bytemuck", - "dyn-stack", + "dyn-stack 0.13.2", "half", "libm", "num-complex", @@ -4945,7 +5048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" dependencies = [ "bytemuck", - "dyn-stack", + "dyn-stack 0.13.2", "half", "libm", "num-complex", @@ -4965,7 +5068,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "gemm-f32 0.18.2", "half", @@ -4983,7 +5086,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.19.0", "gemm-f32 0.19.0", "half", @@ -5001,7 +5104,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "num-complex", "num-traits", @@ -5016,7 +5119,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.19.0", "num-complex", "num-traits", @@ -5031,7 +5134,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.18.2", "num-complex", "num-traits", @@ -5046,7 +5149,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" dependencies = [ - "dyn-stack", + "dyn-stack 0.13.2", "gemm-common 0.19.0", "num-complex", "num-traits", @@ -5055,6 +5158,12 @@ dependencies = [ "seq-macro", ] +[[package]] +name = "generativity" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c81fb5260e37854d09d5c87183309fd8c555b75289427884b25660bc87a85e" + [[package]] name = "generator" version = "0.8.8" @@ -5486,9 +5595,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -5514,23 +5623,14 @@ dependencies = [ "crunchy", "num-traits", "rand 0.9.4", - "rand_distr", + "rand_distr 0.5.1", "serde", "zerocopy", ] [[package]] -name = "hash32" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" +name = "hashbrown" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" @@ -5576,9 +5676,14 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -5590,17 +5695,13 @@ dependencies = [ ] [[package]] -name = "heapless" -version = "0.7.17" +name = "hdrhistogram" +version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" dependencies = [ - "atomic-polyfill", - "hash32", - "rustc_version", - "serde", - "spin 0.9.8", - "stable_deref_trait", + "byteorder", + "num-traits", ] [[package]] @@ -5694,26 +5795,25 @@ dependencies = [ ] [[package]] -name = "hickory-proto" -version = "0.25.2" +name = "hickory-net" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ "async-trait", "bytes", "cfg-if", "data-encoding", - "enum-as-inner", "futures-channel", "futures-io", "futures-util", - "h2 0.4.13", + "h2 0.4.14", + "hickory-proto", "http 1.4.0", "idna", "ipnet", - "once_cell", - "rand 0.9.4", - "ring", + "jni 0.22.4", + "rand 0.10.1", "rustls", "thiserror 2.0.18", "tinyvec", @@ -5723,23 +5823,48 @@ dependencies = [ "url", ] +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "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 = "hickory-resolver" -version = "0.25.2" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" dependencies = [ "cfg-if", "futures-util", + "hickory-net", "hickory-proto", "ipconfig", + "ipnet", + "jni 0.22.4", "moka", + "ndk-context", "once_cell", "parking_lot", - "rand 0.9.4", + "rand 0.10.1", "resolv-conf", "rustls", "smallvec", + "system-configuration 0.7.0", "thiserror 2.0.18", "tokio", "tokio-rustls", @@ -5782,23 +5907,64 @@ dependencies = [ ] [[package]] -name = "hound" -version = "3.5.1" +name = "hotpath" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +checksum = "bff002d5c53fa1c6891f32156b9451d16654bc8a761894d7660b25c0a332d517" +dependencies = [ + "arc-swap", + "cfg-if", + "crossbeam-channel", + "flate2", + "futures-util", + "hdrhistogram", + "hotpath-macros", + "hotpath-meta", + "libc", + "object 0.36.7", + "pin-project-lite", + "prettytable-rs", + "quanta", + "regex", + "rustc-demangle", + "serde", + "serde_json", + "tiny_http", + "tokio", +] [[package]] -name = "html5ever" -version = "0.29.1" +name = "hotpath-macros" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +checksum = "9e2f4ac4534511584b7082657e133dcf3d8727b2f456a6b2a2c3eb02b82c1277" dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hotpath-macros-meta" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a87070853e9402ec79184f8d8d930d7eb86cd274aecdcf973f73b6f40271b0" + +[[package]] +name = "hotpath-meta" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31cfef2b9d280ad754c23b40b50cc74676489597f3cebfe0c180389e08a53ed" +dependencies = [ + "hotpath-macros-meta", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.38.0" @@ -5806,7 +5972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever 0.38.0", + "markup5ever", ] [[package]] @@ -5884,9 +6050,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] @@ -5925,7 +6091,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -6154,9 +6320,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -6183,11 +6349,10 @@ dependencies = [ [[package]] name = "igd-next" -version = "0.16.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +checksum = "bac9a3c8278f43b4cd8463380f4a25653ac843e5b177e1d3eaf849cc9ba10d4d" dependencies = [ - "async-trait", "attohttpc", "bytes", "futures", @@ -6196,7 +6361,7 @@ dependencies = [ "hyper 1.9.0", "hyper-util", "log", - "rand 0.9.4", + "rand 0.10.1", "tokio", "url", "xmltree", @@ -6254,9 +6419,9 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" [[package]] name = "indexmap" @@ -6276,7 +6441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -6413,35 +6578,31 @@ name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ - "memchr", "serde", ] [[package]] name = "iroh" -version = "0.97.0" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feb56e7e4b0ec7fba7efa6a236b016a52b5d927d50244aceb9e20566159b1a32" +checksum = "b98e206e3d3f2642f5c08c413755fc0ac19b54ae1a656af88be03454ce3ed2e6" dependencies = [ "backon", + "blake3", "bytes", "cfg_aliases", + "ctutils", "data-encoding", - "derive_more 2.1.1", + "derive_more", "ed25519-dalek", "futures-util", - "getrandom 0.3.4", + "getrandom 0.4.2", "hickory-resolver", "http 1.4.0", "ipnet", "iroh-base", + "iroh-dns", "iroh-metrics", "iroh-relay", "n0-error", @@ -6453,12 +6614,10 @@ dependencies = [ "noq-udp", "papaya", "pin-project", - "pkarr", - "pkcs8", "portable-atomic", "portmapper", - "rand 0.9.4", - "reqwest 0.12.28", + "rand 0.10.1", + "reqwest 0.13.3", "rustc-hash 2.1.2", "rustls", "rustls-pki-types", @@ -6466,7 +6625,6 @@ dependencies = [ "serde", "smallvec", "strum 0.28.0", - "sync_wrapper 1.0.2", "time", "tokio", "tokio-stream", @@ -6479,24 +6637,50 @@ dependencies = [ [[package]] name = "iroh-base" -version = "0.97.0" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a354e3396b62c14717ee807dfee9a7f43f6dad47e4ac0fd1d49f1ffad14ef0" +checksum = "2160a45265eba3bd290ce698f584c9b088bee47e518e9ec4460d5e5888ef660e" dependencies = [ "curve25519-dalek", "data-encoding", - "derive_more 2.1.1", - "digest 0.11.0-rc.10", + "data-encoding-macro", + "derive_more", + "digest 0.11.3", "ed25519-dalek", + "getrandom 0.4.2", "n0-error", - "rand_core 0.9.5", + "rand 0.10.1", "serde", - "sha2 0.11.0-rc.2", + "sha2 0.11.0", "url", "zeroize", "zeroize_derive", ] +[[package]] +name = "iroh-dns" +version = "1.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8b6d2946350d398c9d2d795bb99b04f22e8414c8a8ad9c5c3c0c5b7899af9a4" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "rand 0.10.1", + "reqwest 0.13.3", + "rustls", + "simple-dns", + "strum 0.28.0", + "tokio", + "tracing", + "url", +] + [[package]] name = "iroh-example-client" version = "0.1.0" @@ -6516,15 +6700,14 @@ dependencies = [ [[package]] name = "iroh-metrics" -version = "0.38.3" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761b45ba046134b11eb3e432fa501616b45c4bf3a30c21717578bc07aa6461dd" +checksum = "d102597d0ee523f17fdb672c532395e634dbe945429284c811430d63bacc0d8a" dependencies = [ "iroh-metrics-derive", "itoa", "n0-error", "portable-atomic", - "postcard", "ryu", "serde", "tracing", @@ -6532,9 +6715,9 @@ dependencies = [ [[package]] name = "iroh-metrics-derive" -version = "0.4.1" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab063c2bfd6c3d5a33a913d4fdb5252f140db29ec67c704f20f3da7e8f92dbf" +checksum = "91c8e0c97f1dc787107f388433c349397c565572fe6406d600ff7bb7b7fe3b30" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -6544,34 +6727,34 @@ dependencies = [ [[package]] name = "iroh-relay" -version = "0.97.0" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d786b260cadfe82ae0b6a9e372e8c78949096a06c857d1c3521355cefced0f55" +checksum = "54f490405e42dd2ecf16be18a3587d2665401e94a498094f12322eaa6d5ebb2b" dependencies = [ "blake3", "bytes", "cfg_aliases", "data-encoding", - "derive_more 2.1.1", - "getrandom 0.3.4", + "derive_more", + "getrandom 0.4.2", "hickory-resolver", "http 1.4.0", "http-body-util", "hyper 1.9.0", "hyper-util", "iroh-base", + "iroh-dns", "iroh-metrics", - "lru", + "lru 0.18.0", "n0-error", "n0-future", "noq", "noq-proto", "num_enum", "pin-project", - "pkarr", "postcard", - "rand 0.9.4", - "reqwest 0.12.28", + "rand 0.10.1", + "reqwest 0.13.3", "rustls", "rustls-pki-types", "serde", @@ -6586,7 +6769,6 @@ dependencies = [ "vergen-gitcl", "webpki-roots 1.0.7", "ws_stream_wasm", - "z32", ] [[package]] @@ -6746,6 +6928,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "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 = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -6801,9 +7013,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -6835,15 +7047,15 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.46.2" +version = "0.46.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50180452e7808015fe083eae3efcf1ec98b89b45dd8cc204f7b4a6b7b81ea675" +checksum = "6a5fe5206f06e589caf25e79fc05ccdf91fca745685fe9fe1a13bbdfb479a631" dependencies = [ "ahash", "bytecount", "data-encoding", "email_address", - "fancy-regex 0.17.0", + "fancy-regex 0.18.0", "fraction", "getrandom 0.3.4", "idna", @@ -6948,26 +7160,14 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "libc", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.14.0", - "selectors 0.24.0", -] - [[package]] name = "lab" version = "0.11.0" @@ -7018,9 +7218,9 @@ dependencies = [ [[package]] name = "libbz2-rs-sys" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" @@ -7100,10 +7300,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", "libc", - "plain", - "redox_syscall 0.7.4", ] [[package]] @@ -7198,6 +7395,16 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "line-clipping" version = "0.3.7" @@ -7249,9 +7456,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-cpp-4" -version = "0.2.50" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcf0cd079ad2f022bf031670df8ba456c21912563e820aa88e7102e33afb194" +checksum = "7664b6e80f608ab439d7380a418a1c8506078d865c7e846da58a12dffbbac948" dependencies = [ "enumflags2", "llama-cpp-sys-4", @@ -7261,26 +7468,30 @@ dependencies = [ [[package]] name = "llama-cpp-sys-4" -version = "0.2.50" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca95ff4c86ec27cba44c939e7161bc66a7edef083f7e59186e3bf4e5778a76a" +checksum = "3ef4a087ff62bc2f024e17457f94ac9921771406d764e46f17f6c0b71bc42fb3" dependencies = [ "bindgen 0.72.1", "cc", "cmake", "glob", + "patch-apply", "winreg 0.56.0", ] [[package]] name = "llmfit-core" -version = "0.9.14" +version = "0.9.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e0d5d303daeb9a26f414d6e97cc123fa924314177bba06b4dd03b65a2313f0" +checksum = "345458bd638f83e330622af19a46c586a1e06d9654bb7b237b5579d6f51a18bb" dependencies = [ + "dirs", "http 1.4.0", + "regex", "serde", "serde_json", + "serde_yml", "sysinfo 0.38.4", "ureq 3.3.0", "which", @@ -7332,6 +7543,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -7378,12 +7598,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mac-addr" version = "0.3.0" @@ -7511,20 +7725,6 @@ dependencies = [ "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", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -7532,7 +7732,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril 0.5.0", + "tendril", "web_atoms", ] @@ -7542,17 +7742,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "matchers" version = "0.2.0" @@ -7562,18 +7751,28 @@ dependencies = [ "regex-automata", ] -[[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 = "matrixcompare" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37832ba820e47c93d66b4360198dccb004b43c74abc3ac1ce1fed54e65a80445" +dependencies = [ + "matrixcompare-core", + "num-traits", +] + +[[package]] +name = "matrixcompare-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0bdabb30db18805d5290b3da7ceaccbddba795620b86c02145d688e04900a73" + [[package]] name = "matrixmultiply" version = "0.3.10" @@ -7666,6 +7865,21 @@ dependencies = [ "paste", ] +[[package]] +name = "metal" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3572083504c43e14aec05447f8a3d57cce0f66d7a3c1b9058572eca4d70ab9" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + [[package]] name = "metal" version = "0.32.0" @@ -7854,7 +8068,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.2" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" dependencies = [ "crossbeam-channel", "dpi", @@ -7865,10 +8081,10 @@ dependencies = [ "objc2-core-foundation", "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]] @@ -7933,9 +8149,9 @@ dependencies = [ [[package]] name = "n0-error" -version = "0.1.3" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" +checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212" dependencies = [ "n0-error-macros", "spez", @@ -7943,9 +8159,9 @@ dependencies = [ [[package]] name = "n0-error-macros" -version = "0.1.3" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" +checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828" dependencies = [ "proc-macro2", "quote", @@ -7959,7 +8175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" dependencies = [ "cfg_aliases", - "derive_more 2.1.1", + "derive_more", "futures-buffered", "futures-lite", "futures-util", @@ -7975,11 +8191,11 @@ dependencies = [ [[package]] name = "n0-watcher" -version = "0.6.1" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38795f7932e6e9d1c6e989270ef5b3ff24ebb910e2c9d4bed2d28d8bae3007dc" +checksum = "928d8039a66cce5efcfd35e88b32d3defc8eba630b3ac451522997f563956a52" dependencies = [ - "derive_more 2.1.1", + "derive_more", "n0-error", "n0-future", ] @@ -8011,7 +8227,106 @@ dependencies = [ ] [[package]] -name = "native-tls" +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "nalgebra-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "nano-gemm" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5ba2bea1c00e53de11f6ab5bd0761ba87dc0045d63b0c87ee471d2d3061376" +dependencies = [ + "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 = "nano-gemm-c32" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a40449e57a5713464c3a1208c4c3301c8d29ee1344711822cf022bc91373a91b" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", +] + +[[package]] +name = "nano-gemm-c64" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743a6e6211358fba85d1009616751e4107da86f4c95b24e684ce85f25c25b3bf" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", +] + +[[package]] +name = "nano-gemm-codegen" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963bf7c7110d55430169dc74c67096375491ed580cd2ef84842550ac72e781fa" + +[[package]] +name = "nano-gemm-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3fc4f83ae8861bad79dc3c016bd6b0220da5f9de302e07d3112d16efc24aa6" + +[[package]] +name = "nano-gemm-f32" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3681b7ce35658f79da94b7f62c60a005e29c373c7111ed070e3bf64546a8bb" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", +] + +[[package]] +name = "nano-gemm-f64" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc1e619ed04d801809e1f63e61b669d380c4119e8b0cdd6ed184c6b111f046d8" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", +] + +[[package]] +name = "native-tls" version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" @@ -8107,24 +8422,26 @@ dependencies = [ [[package]] name = "netdev" -version = "0.40.1" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" +checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" dependencies = [ "block2 0.6.2", "dispatch2", - "dlopen2 0.5.0", + "dlopen2", "ipnet", "libc", "mac-addr", "netlink-packet-core", - "netlink-packet-route", + "netlink-packet-route 0.29.0", "netlink-sys", "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation 0.3.2", "objc2-system-configuration", "once_cell", "plist", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8148,6 +8465,18 @@ dependencies = [ "netlink-packet-core", ] +[[package]] +name = "netlink-packet-route" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8919612f6028ab4eacbbfe1234a9a43e3722c6e0915e7ff519066991905092" +dependencies = [ + "bitflags 2.11.1", + "libc", + "log", + "netlink-packet-core", +] + [[package]] name = "netlink-proto" version = "0.12.0" @@ -8177,14 +8506,14 @@ dependencies = [ [[package]] name = "netwatch" -version = "0.15.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1b27babe89ef9f2237bc6c028bea24fa84163a1b6f8f17ff93573ebd7d861f" +checksum = "b5bfbba77b994ce69f1d40fc66fd8abbd23df62ce4aea61fbb34d638106a2549" dependencies = [ "atomic-waker", "bytes", "cfg_aliases", - "derive_more 2.1.1", + "derive_more", "js-sys", "libc", "n0-error", @@ -8192,7 +8521,7 @@ dependencies = [ "n0-watcher", "netdev", "netlink-packet-core", - "netlink-packet-route", + "netlink-packet-route 0.30.0", "netlink-proto", "netlink-sys", "noq-udp", @@ -8273,8 +8602,7 @@ dependencies = [ [[package]] name = "neutts" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95569110f3d2af0100f7d5094880c28827ab844a8f579bd2b8a9a9beef704073" +source = "git+https://github.com/eugenehp/neutts-rs?branch=main#65771f3a91a811725ccb51ba6f679528cd6e0325" dependencies = [ "anyhow", "burn", @@ -8342,9 +8670,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.1", "cfg-if", @@ -8354,19 +8682,13 @@ dependencies = [ [[package]] name = "no_std_io2" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ "memchr", ] -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "nom" version = "7.1.3" @@ -8386,6 +8708,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom 7.1.3", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -8394,12 +8727,13 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "noq" -version = "0.17.0" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df966fb44ac763bc86da97fa6c811c54ae82ef656575949f93c6dae0c9f09bf" +checksum = "22739e0831e40f5ab7d6ac5317ed80bfe5fb3f44be57d23fa2eea8bff83fb303" dependencies = [ "bytes", "cfg_aliases", + "derive_more", "noq-proto", "noq-udp", "pin-project-lite", @@ -8415,19 +8749,19 @@ dependencies = [ [[package]] name = "noq-proto" -version = "0.16.0" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c61b72abd670eebc05b5cf720e077b04a3ef3354bc7bc19f1c3524cb424db7b" +checksum = "7cee32450cf726b223ac4154003c93cb52fbde159ab1240990e88945bf3ae35e" dependencies = [ "aes-gcm", "bytes", - "derive_more 2.1.1", + "derive_more", "enum-assoc", - "fastbloom", - "getrandom 0.3.4", + "getrandom 0.4.2", "identity-hash", "lru-slab", - "rand 0.9.4", + "rand 0.10.1", + "rand_pcg", "ring", "rustc-hash 2.1.2", "rustls", @@ -8442,9 +8776,9 @@ dependencies = [ [[package]] name = "noq-udp" -version = "0.9.0" +version = "1.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb9be4fedd6b98f3ba82ccd3506f4d0219fb723c3f97c67e12fe1494aa020e44" +checksum = "78633d1fe1bde91d12bcabb230ac9edb890857414c6d44f3212e0d309525b5ff" dependencies = [ "cfg_aliases", "libc", @@ -8473,16 +8807,16 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.16.0" +version = "4.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" dependencies = [ "futures-lite", "log", "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus 5.14.0", + "zbus 5.15.0", ] [[package]] @@ -8495,27 +8829,23 @@ dependencies = [ ] [[package]] -name = "ntapi" -version = "0.4.3" +name = "npyz" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +checksum = "9f0e759e014e630f90af745101b614f761306ddc541681e546649068e25ec1b9" dependencies = [ - "winapi", + "byteorder", + "num-bigint", + "py_literal", ] [[package]] -name = "ntimestamp" -version = "1.0.0" +name = "ntapi" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ - "base32", - "document-features", - "getrandom 0.2.17", - "httpdate", - "js-sys", - "once_cell", - "serde", + "winapi", ] [[package]] @@ -8565,13 +8895,14 @@ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "bytemuck", "num-traits", + "rand 0.8.6", ] [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -8923,6 +9254,20 @@ dependencies = [ "objc2-metal", ] +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", + "objc2-security-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -9068,6 +9413,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-system-configuration" version = "0.3.2" @@ -9127,6 +9482,15 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.37.3" @@ -9169,9 +9533,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onig" -version = "6.5.1" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ "bitflags 2.11.1", "libc", @@ -9181,9 +9545,9 @@ dependencies = [ [[package]] name = "onig_sys" -version = "69.9.1" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" dependencies = [ "cc", "pkg-config", @@ -9203,9 +9567,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.4" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "dunce", "is-wsl", @@ -9232,9 +9596,9 @@ dependencies = [ [[package]] name = "openblas-build" -version = "0.10.15" +version = "0.10.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd235aa8876fa5c4be452efde09b9b8bafa19aea0bf14a4926508213082439a3" +checksum = "bb9c85e9e7dd5acdc67b9f3f0c99656b550df716bc63540c6a224a920754a5c2" dependencies = [ "anyhow", "cc", @@ -9246,9 +9610,9 @@ dependencies = [ [[package]] name = "openblas-src" -version = "0.10.15" +version = "0.10.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fccd2c4f5271ab871f2069cb6f1a13ef2c0db50e1145ce03428ee541f4c63c4f" +checksum = "f1a81a5e467f1861ad6ac32d5ec1690ad097d19854753b7424250fe27da46b98" dependencies = [ "dirs", "openblas-build", @@ -9258,15 +9622,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags 2.11.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -9290,9 +9653,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -9324,6 +9687,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c1f9f56e534ac6a9b8a4600bdf0f530fb393b5f393e7b4d03489c3cf0c3f01" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -9476,16 +9848,16 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] [[package]] name = "parquet" -version = "58.1.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d3f9f2205199603564127932b89695f52b62322f541d0fc7179d57c2e1c9877" +checksum = "5dafa7d01085b62a47dd0c1829550a0a36710ea9c4fe358a05a85477cec8a908" dependencies = [ "ahash", "arrow-array", @@ -9498,7 +9870,7 @@ dependencies = [ "bytes", "chrono", "half", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "num-bigint", "num-integer", "num-traits", @@ -9522,6 +9894,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "patch-apply" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe95476ec50a4e9b95ed12a4677ff5996aba4329bf2535fb21c49afaad20809" +dependencies = [ + "chrono", + "nom 7.1.3", + "nom_locate", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -9616,26 +9999,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - [[package]] name = "phf" version = "0.11.3" @@ -9657,16 +10020,6 @@ dependencies = [ "serde", ] -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - [[package]] name = "phf_codegen" version = "0.11.3" @@ -9687,24 +10040,6 @@ dependencies = [ "phf_shared 0.13.1", ] -[[package]] -name = "phf_generator" -version = "0.8.0" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.8.6", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.6", -] - [[package]] name = "phf_generator" version = "0.11.3" @@ -9725,20 +10060,6 @@ dependencies = [ "phf_shared 0.13.1", ] -[[package]] -name = "phf_macros" -version = "0.10.0" -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", -] - [[package]] name = "phf_macros" version = "0.11.3" @@ -9765,31 +10086,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - [[package]] name = "phf_shared" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] @@ -9798,23 +10101,23 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -9866,30 +10169,11 @@ dependencies = [ "system-deps 7.0.8", ] -[[package]] -name = "pkarr" -version = "5.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bfb9143bbba379f246211eb68074d78db9cc048e4c5701f3b0e6cb1ec67ca2" -dependencies = [ - "base32", - "bytes", - "cfg_aliases", - "document-features", - "ed25519-dalek", - "getrandom 0.4.2", - "ntimestamp", - "self_cell", - "serde", - "simple-dns", - "thiserror 2.0.18", -] - [[package]] name = "pkcs8" -version = "0.11.0-rc.11" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der", "spki", @@ -9901,21 +10185,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.38.4", + "quick-xml 0.39.4", "serde", "time", ] @@ -10026,23 +10304,22 @@ dependencies = [ [[package]] name = "portmapper" -version = "0.15.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74748bc706fa6b6aebac6bbe0bbe0de806b384cb5c557ea974f771360a4e3858" +checksum = "aec2a8809e3f7dba624776bb223da9fed49c413c60b3bef21aadcb67a5e35944" dependencies = [ "base64 0.22.1", "bytes", - "derive_more 2.1.1", - "futures-lite", - "futures-util", + "derive_more", "hyper-util", "igd-next", "iroh-metrics", "libc", "n0-error", + "n0-future", "netwatch", "num_enum", - "rand 0.9.4", + "rand 0.10.1", "serde", "smallvec", "socket2 0.6.3", @@ -10063,7 +10340,6 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless", "postcard-derive", "serde", ] @@ -10115,6 +10391,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "presser" version = "0.3.1" @@ -10145,6 +10432,19 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width 0.1.14", +] + [[package]] name = "primal-check" version = "0.3.4" @@ -10207,12 +10507,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -10224,18 +10518,18 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", "syn 2.0.117", @@ -10293,6 +10587,18 @@ dependencies = [ "cc", ] +[[package]] +name = "pulp" +version = "0.18.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0a01a0dc67cf4558d279f0c25b0962bd08fc6dec0137699eae304103e882fe6" +dependencies = [ + "bytemuck", + "libm", + "num-complex", + "reborrow", +] + [[package]] name = "pulp" version = "0.21.5" @@ -10336,6 +10642,19 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "py_literal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102df7a3d46db9d3891f178dcc826dc270a6746277a9ae6436f8d29fd490a8e1" +dependencies = [ + "num-bigint", + "num-complex", + "num-traits", + "pest", + "pest_derive", +] + [[package]] name = "qoi" version = "0.4.1" @@ -10362,6 +10681,21 @@ dependencies = [ "qrcodegen", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -10394,18 +10728,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] @@ -10562,6 +10887,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + [[package]] name = "rand_distr" version = "0.5.1" @@ -10572,6 +10907,15 @@ dependencies = [ "rand 0.9.4", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -10613,7 +10957,7 @@ dependencies = [ "indoc", "itertools 0.14.0", "kasuari", - "lru", + "lru 0.16.4", "strum 0.27.2", "thiserror 2.0.18", "unicode-segmentation", @@ -10789,15 +11133,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -10842,9 +11177,9 @@ dependencies = [ [[package]] name = "referencing" -version = "0.46.2" +version = "0.46.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb0c66c7b78c1da928bee668b5cc638c678642ff587faff6e6222f797be9d4c" +checksum = "69e4e17ef386c5383591d07623d3de49cbc601156e7582973e6db98d66a57de2" dependencies = [ "ahash", "fluent-uri", @@ -10950,7 +11285,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.13", + "h2 0.4.14", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -10988,9 +11323,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", @@ -11108,11 +11443,28 @@ dependencies = [ "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.4" +version = "0.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5aa3167d3ce741e503553e8522fdc0c9e231059941e6bb91c333ee1a31947c0" +checksum = "7663957cfd3ef08e719d34d7e7cd6856e43a1436e1a4576e549562cbd48bf11a" dependencies = [ "anyhow", "base64 0.22.1", @@ -11122,12 +11474,125 @@ dependencies = [ "iroh", "log", "lz4_flex", - "rlsl", + "rlsl 0.0.5", + "serde", + "serde_json", + "snap", + "tokio", + "zstd", +] + +[[package]] +name = "rlx" +version = "0.2.0" +dependencies = [ + "anyhow", + "rlx-driver", + "rlx-gguf", + "rlx-ir", + "rlx-macros", + "rlx-models", + "rlx-opt", + "rlx-runtime", +] + +[[package]] +name = "rlx-cpu" +version = "0.2.0" +dependencies = [ + "half", + "rayon", + "rlx-gguf", + "rlx-ir", + "rlx-opt", +] + +[[package]] +name = "rlx-driver" +version = "0.2.0" +dependencies = [ + "half", + "rlx-ir", +] + +[[package]] +name = "rlx-gguf" +version = "0.2.0" +dependencies = [ + "anyhow", + "bytemuck", + "half", +] + +[[package]] +name = "rlx-ir" +version = "0.2.0" +dependencies = [ + "smallvec", +] + +[[package]] +name = "rlx-macros" +version = "0.2.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "rlx-metal" +version = "0.2.0" +dependencies = [ + "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 = "rlx-models" +version = "0.2.0" +dependencies = [ + "anyhow", + "bytemuck", + "half", + "rlx-cpu", + "rlx-gguf", + "rlx-ir", + "rlx-runtime", + "safetensors 0.7.0", + "serde", + "serde_json", + "tokenizers", +] + +[[package]] +name = "rlx-opt" +version = "0.2.0" +dependencies = [ + "rlx-ir", +] + +[[package]] +name = "rlx-runtime" +version = "0.2.0" +dependencies = [ + "half", + "rlx-cpu", + "rlx-driver", + "rlx-ir", + "rlx-macros", + "rlx-metal", + "rlx-opt", "serde", "serde_json", - "snap", - "tokio", - "zstd", ] [[package]] @@ -11164,9 +11629,9 @@ 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", @@ -11402,9 +11867,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", @@ -11438,9 +11903,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 +11913,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 +11973,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" @@ -11694,24 +12168,6 @@ 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" @@ -11719,24 +12175,18 @@ 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", + "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" @@ -11901,11 +12351,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -11920,9 +12371,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -11943,6 +12394,31 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[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" @@ -11984,16 +12460,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 +12480,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 +12499,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]] @@ -12084,9 +12556,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 = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] [[package]] name = "simd-adler32" @@ -12094,6 +12579,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 +12606,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", ] [[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.130-rc.31" dependencies = [ "anyhow", "base64 0.22.1", @@ -12273,11 +12762,14 @@ dependencies = [ "neurosky", "notify", "notify-rust", + "objc2 0.6.4", + "objc2-app-kit", "osf-rs", + "pollster", "rand 0.10.1", "regex", "reve-rs", - "rlsl", + "rlsl 0.0.4", "rusqlite", "serde", "serde_json", @@ -12324,6 +12816,7 @@ dependencies = [ "ureq 3.3.0", "urlencoding", "uuid", + "wgpu", "zstd", "zuna-rs", ] @@ -12353,7 +12846,9 @@ dependencies = [ "dirs", "fastembed", "hex", + "hf-hub 0.5.0", "rand 0.10.1", + "rlx", "serde", "serde_json", "sha2 0.10.9", @@ -12369,6 +12864,7 @@ dependencies = [ "skill-settings", "tempfile", "thiserror 2.0.18", + "tokenizers", "tokio", "tracing", ] @@ -12455,6 +12951,7 @@ dependencies = [ "serde_json", "skill-constants", "skill-data", + "skill-devices", "skill-eeg", "ureq 3.3.0", ] @@ -12481,9 +12978,9 @@ dependencies = [ "http 1.4.0", "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 +13045,7 @@ dependencies = [ name = "skill-label-index" version = "0.0.1" dependencies = [ + "accelerate-src", "fast-hnsw", "rusqlite", "serde", @@ -12555,6 +13053,7 @@ dependencies = [ "skill-constants", "skill-data", "tempfile", + "turbovec", ] [[package]] @@ -12570,6 +13069,7 @@ dependencies = [ "hf-hub 0.5.0", "llama-cpp-4", "log", + "rlx", "rusqlite", "serde", "serde_json", @@ -12605,7 +13105,7 @@ dependencies = [ "iroh", "log", "rand 0.10.1", - "rlsl", + "rlsl 0.0.4", "rlsl-iroh", "serde", "serde_json", @@ -12640,6 +13140,7 @@ dependencies = [ "cubecl", "fast-umap", "half", + "hotpath", "rusqlite", "serde", "serde_json", @@ -12690,6 +13191,8 @@ dependencies = [ "skill-eeg", "skill-llm", "skill-tts", + "tempfile", + "tracing", ] [[package]] @@ -12755,6 +13258,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "skill-tty" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "libc", +] + [[package]] name = "skill-vision" version = "0.1.0" @@ -12879,7 +13392,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", @@ -12934,9 +13447,6 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] [[package]] name = "spin" @@ -12981,9 +13491,9 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" +checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee" dependencies = [ "cc", "js-sys", @@ -13022,6 +13532,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "statrs" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f697a07e4606a0a25c044de247e583a330dbb1731d11bc7350b81f48ad567255" +dependencies = [ + "approx", + "nalgebra", + "num-traits", + "rand 0.8.6", +] + [[package]] name = "steegformer" version = "0.1.0" @@ -13047,19 +13569,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 +13581,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" @@ -13414,10 +13911,9 @@ dependencies = [ "core-graphics", "crossbeam-channel", "dispatch2", - "dlopen2 0.8.2", + "dlopen2", "dpi", "gdkwayland-sys", - "gdkx11-sys", "gtk", "jni 0.21.1", "libc", @@ -13437,24 +13933,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.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" dependencies = [ "bitflags 2.11.1", "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 +13972,7 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "windows-version", + "x11-dl", ] [[package]] @@ -13490,9 +13988,9 @@ 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", @@ -13513,9 +14011,9 @@ checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[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", @@ -13542,7 +14040,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.2", + "reqwest 0.13.3", "serde", "serde_json", "serde_repr", @@ -13565,9 +14063,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 +14079,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 +14111,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 +14125,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,7 +14136,6 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -13679,9 +14175,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 +14192,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows 0.61.3", - "zbus 5.14.0", + "zbus 5.15.0", ] [[package]] @@ -13711,9 +14207,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 +14217,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus 5.14.0", + "zbus 5.15.0", ] [[package]] @@ -13740,7 +14236,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.13.2", + "reqwest 0.13.3", "rustls", "semver", "serde", @@ -13759,9 +14255,9 @@ 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", @@ -13784,9 +14280,9 @@ 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", @@ -13798,36 +14294,36 @@ dependencies = [ "percent-encoding", "raw-window-handle", "softbuffer", - "tao 0.34.8", + "tao 0.35.2", "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", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf 0.13.1", + "plist", "proc-macro2", "quote", "regex", @@ -13839,7 +14335,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 +14344,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]] @@ -13882,17 +14378,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" @@ -13971,7 +14456,7 @@ dependencies = [ "phf 0.11.3", "sha2 0.10.9", "signal-hook", - "siphasher 1.0.2", + "siphasher", "terminfo", "termios", "thiserror 1.0.69", @@ -14129,6 +14614,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 +14696,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", @@ -14327,20 +14824,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", + "getrandom 0.4.2", "http 1.4.0", "httparse", - "rand 0.9.4", + "rand 0.10.1", "ring", "rustls-pki-types", + "sha1_smol", "simdutf8", "tokio", "tokio-rustls", @@ -14386,7 +14884,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -14449,7 +14947,7 @@ 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 +14956,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]] @@ -14502,9 +15000,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", @@ -14515,7 +15013,6 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", - "iri-string", "mime", "mime_guess", "percent-encoding", @@ -14525,7 +15022,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", - "tracing", + "url", ] [[package]] @@ -14726,9 +15223,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,17 +15237,16 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b1eee4f03c9961e4bbae9d08f0a55ea35ff67e9dae6da005f7636058d8a5688" +source = "git+https://github.com/eugenehp/tribev2-rs?branch=main#714cd4d7790125cf927996ad31490298bc152714" dependencies = [ "anyhow", "burn", @@ -14846,6 +15342,24 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "turbovec" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0185030ca15ea4f8b00c22f02021baa613c2ed3fc83449e32c368debd4a4fe37" +dependencies = [ + "blas-src 0.10.0", + "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 = "twox-hash" version = "2.1.2" @@ -15307,32 +15821,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]] @@ -15453,9 +15956,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -15466,9 +15969,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -15476,9 +15979,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -15486,9 +15989,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -15499,9 +16002,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -15624,7 +16127,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", ] @@ -15656,9 +16159,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -15682,8 +16185,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]] @@ -15989,7 +16492,7 @@ dependencies = [ "naga", "ndk-sys", "objc", - "ordered-float 4.6.0", + "ordered-float 5.0.0", "parking_lot", "portable-atomic", "portable-atomic-util", @@ -16029,6 +16532,16 @@ 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" @@ -16675,15 +17188,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", ] @@ -16879,9 +17389,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", @@ -17016,7 +17526,7 @@ dependencies = [ "widestring", "windows 0.62.2", "xcb", - "zbus 5.14.0", + "zbus 5.15.0", ] [[package]] @@ -17048,9 +17558,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" @@ -17120,12 +17630,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 +17664,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ "async-broadcast", "async-executor", @@ -17187,10 +17691,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.15.0", + "zbus_names 4.3.2", + "zvariant 5.11.0", ] [[package]] @@ -17208,17 +17712,17 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" 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.11.0", + "zvariant_utils 3.3.1", ] [[package]] @@ -17234,13 +17738,13 @@ 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.11.0", ] [[package]] @@ -17265,9 +17769,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", ] @@ -17505,16 +18009,16 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" 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.11.0", + "zvariant_utils 3.3.1", ] [[package]] @@ -17532,15 +18036,15 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils 3.3.0", + "zvariant_utils 3.3.1", ] [[package]] @@ -17556,13 +18060,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" 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..0cb8256d 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", @@ -44,6 +44,7 @@ members = [ "crates/skill-tools", "crates/skill-tray", "crates/skill-tts", + "crates/skill-tty", "crates/skill-vision", "crates/iroh_test_client", "crates/iroh-example-client", @@ -68,8 +69,6 @@ members = [ [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,11 +77,12 @@ 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" } +# skill-llm uses llama-cpp-4 0.3.0. neutts/tribev2 main are on 0.3.0; crates.io 0.1.1 / 0.0.4 still pin ^0.2.x. +tribev2 = { git = "https://github.com/eugenehp/tribev2-rs", branch = "main", package = "tribev2" } +neutts = { git = "https://github.com/eugenehp/neutts-rs", branch = "main" } # 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) @@ -115,6 +115,11 @@ 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 — optional path dep (features set per crate). Sibling layout: +# skill/ (this repo) +# rlx/rlx/ (https://github.com/MIT-RLX/rlx — CI + scripts/ensure-rlx.sh) +rlx = { path = "../rlx/rlx", default-features = false } + # ── Workspace lints ──────────────────────────────────────────────────────────── # # Consistent lint policy across every crate. Individual crates inherit these 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/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/unreleased.md b/changes/unreleased.md index 2f16bedc..7f7ed7f4 100644 --- a/changes/unreleased.md +++ b/changes/unreleased.md @@ -3,3 +3,14 @@ ### UI - Add "Force Restart" button to the engine status hover panel on the dashboard + +### 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..8dc26990 100644 --- a/crates/skill-constants/src/lib.rs +++ b/crates/skill-constants/src/lib.rs @@ -588,6 +588,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-daemon-state/Cargo.toml b/crates/skill-daemon-state/Cargo.toml index 3274e969..c26143bd 100644 --- a/crates/skill-daemon-state/Cargo.toml +++ b/crates/skill-daemon-state/Cargo.toml @@ -8,6 +8,8 @@ description = "Shared state types for skill-daemon — extracted for parallel co [features] default = ["llm"] llm = ["skill-llm/llm"] +text-embeddings-rlx = ["dep:hf-hub", "dep:rlx", "dep:tokenizers"] +text-embeddings-rlx-metal = ["text-embeddings-rlx", "rlx?/metal", "rlx?/blas-accelerate"] [dependencies] anyhow = { workspace = true } @@ -15,12 +17,15 @@ thiserror = { workspace = true } base64 = { workspace = true } dirs = "6" fastembed = { version = "5.13.0", features = ["ort-download-binaries-native-tls"] } +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", "models"] } 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 +34,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..bd052ea9 100644 --- a/crates/skill-daemon-state/src/state.rs +++ b/crates/skill-daemon-state/src/state.rs @@ -45,6 +45,12 @@ 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, } /// Shared application state threaded through all axum handlers. @@ -158,6 +164,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 +300,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 +332,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..ae269caa 100644 --- a/crates/skill-daemon-state/src/text_embedder.rs +++ b/crates/skill-daemon-state/src/text_embedder.rs @@ -1,21 +1,55 @@ // 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 { + FastEmbed(fastembed::TextEmbedding), + #[cfg(feature = "text-embeddings-rlx")] + Rlx(RlxTextEmbedding), +} + /// 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 +65,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 +91,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 +151,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 +179,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,7 +192,306 @@ 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 => { + 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)) + } + TextEmbeddingBackend::Rlx => load_rlx_embedder(code, rlx_device, rlx_max_seq), + } +} + +fn embed_with_loaded(model: &mut LoadedTextEmbedder, texts: Vec<&str>) -> Result>> { + match model { + LoadedTextEmbedder::FastEmbed(model) => Ok(model.embed(texts, None)?), + #[cfg(feature = "text-embeddings-rlx")] + LoadedTextEmbedder::Rlx(model) => model.embed(texts), + } +} + +#[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(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); + 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::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; + } } } diff --git a/crates/skill-daemon/Cargo.toml b/crates/skill-daemon/Cargo.toml index 0ff3e4b9..2d800488 100644 --- a/crates/skill-daemon/Cargo.toml +++ b/crates/skill-daemon/Cargo.toml @@ -12,6 +12,10 @@ 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-metal = ["llm-rlx", "skill-llm/llm-rlx-metal"] +text-embeddings-rlx = ["skill-daemon-state/text-embeddings-rlx"] +text-embeddings-rlx-metal = ["text-embeddings-rlx", "skill-daemon-state/text-embeddings-rlx-metal"] # 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"] @@ -21,7 +25,7 @@ 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"] +embed-zuna-gpu-f16 = ["embed-zuna", "zuna-rs/wgpu-f16", "burn/wgpu", "dep:half", "dep:wgpu", "dep:pollster"] # 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"] @@ -79,7 +83,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" } @@ -110,6 +114,8 @@ steegformer = { version = "0.1.0", default-features = false, features = 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 } +wgpu = { version = "26", optional = true } +pollster = { version = "0.4", optional = true } burn-ndarray = { version = "0.20.1", default-features = false, features = ["std", "simd", "multi-threads"], optional = true } ndarray = { version = "0.17", optional = true } skill-location = { path = "../skill-location" } @@ -133,6 +139,8 @@ tokio-tungstenite = "0.28" # macOS: use Apple Accelerate for BLAS (AMX coprocessor on M-series) [target.'cfg(target_os = "macos")'.dependencies] burn-ndarray = { version = "0.20.1", default-features = false, features = ["blas-accelerate"], optional = true } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication", "NSPasteboard"] } # Linux: use system OpenBLAS when available [target.'cfg(target_os = "linux")'.dependencies] 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..a224cbfc 100644 --- a/crates/skill-daemon/src/background.rs +++ b/crates/skill-daemon/src/background.rs @@ -168,6 +168,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 +201,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/cmd_dispatch/data_cmds.rs b/crates/skill-daemon/src/cmd_dispatch/data_cmds.rs index 3e9bc21a..76d5602d 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; diff --git a/crates/skill-daemon/src/cmd_dispatch/llm_cmds.rs b/crates/skill-daemon/src/cmd_dispatch/llm_cmds.rs index 2b3a1a89..ac36da8d 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 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/worker.rs b/crates/skill-daemon/src/embed/worker.rs index 3e6cc7d9..86d48efa 100644 --- a/crates/skill-daemon/src/embed/worker.rs +++ b/crates/skill-daemon/src/embed/worker.rs @@ -628,19 +628,38 @@ fn load_encoder(config: &ExgModelConfig, _skill_dir: &Path) -> Option { } #[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))); + // Preflight the adapter: skip f16 unless wgpu advertises + // SHADER_F16. On Vulkan/DX12 without storageInputOutput16, + // naga's validator panics on f16 even when the driver claims + // shaderFloat16 — the catch_unwind below remains as a safety + // net for the (rare) case where the preflight passes but + // burn/zuna still fail at compile time. + if !wgpu_supports_shader_f16() { + info!("adapter lacks SHADER_F16 — skipping ZUNA GPU f16, trying GPU f32"); + } else { + let f16_result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load_zuna_gpu_f16(config))); + match f16_result { + Ok(Some(s)) => { + info!("ZUNA GPU f16 encoder loaded"); + return Some(Encoder::ZunaGpuF16(Box::new(s))); + } + Ok(None) => warn!("GPU f16 unavailable, trying GPU f32"), + Err(_) => warn!("ZUNA GPU f16 panicked despite preflight — falling back to GPU f32"), + } } - 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))); + let f32_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load_zuna_gpu(config))); + match f32_result { + Ok(Some(s)) => { + info!("ZUNA GPU encoder loaded"); + return Some(Encoder::ZunaGpu(Box::new(s))); + } + Ok(None) => warn!("GPU f32 unavailable, falling back to CPU"), + Err(_) => warn!("ZUNA GPU f32 panicked — falling back to CPU"), } - warn!("GPU f32 unavailable, falling back to CPU"); } load_zuna(config) .map(|s| { @@ -1069,6 +1088,38 @@ fn encode_zuna_gpu(state: &ZunaGpuState, msg: &EpochMsg) -> Option> { // Currently unused for batch reembed (burn f16→f32 extraction bug). // Kept for future use when zuna-rs/burn fix the TypeMismatch issue. +/// Probe the default wgpu adapter once and report whether it exposes +/// `SHADER_F16`. Cached per-process — adapter selection doesn't change at +/// runtime and `request_adapter` is non-trivial. +#[cfg(feature = "embed-zuna-gpu-f16")] +fn wgpu_supports_shader_f16() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + let result = std::panic::catch_unwind(|| { + let instance = wgpu::Instance::default(); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + })) + .ok()?; + Some(adapter.features().contains(wgpu::Features::SHADER_F16)) + }); + match result { + Ok(Some(supported)) => supported, + Ok(None) => { + warn!("wgpu adapter request failed during SHADER_F16 preflight"); + false + } + Err(_) => { + warn!("wgpu SHADER_F16 preflight panicked"); + false + } + } + }) +} + #[cfg(feature = "embed-zuna-gpu-f16")] #[allow(dead_code)] fn load_zuna_gpu_f16(config: &ExgModelConfig) -> Option { @@ -1255,10 +1306,12 @@ pub fn load_encoder_public(config: &ExgModelConfig, skill_dir: &Path) -> Option< // 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))); + let f32_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load_zuna_gpu(config))); + match f32_result { + Ok(Some(gpu)) => return Some(PublicEncoder::Gpu(Box::new(gpu))), + Ok(None) => warn!("GPU f32 unavailable, falling back to CPU"), + Err(_) => warn!("ZUNA GPU f32 panicked during batch-reembed load — falling back to CPU"), } - warn!("GPU f32 unavailable, falling back to CPU"); } } load_encoder(config, skill_dir).map(PublicEncoder::Cpu) diff --git a/crates/skill-daemon/src/handlers.rs b/crates/skill-daemon/src/handlers.rs index c4068ba6..4f7b5a26 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; @@ -1527,17 +1530,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 +1595,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..60821bcd 100644 --- a/crates/skill-daemon/src/idle_reembed.rs +++ b/crates/skill-daemon/src/idle_reembed.rs @@ -6,13 +6,72 @@ //! 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; +const NO_PROGRESS_BACKOFF: Duration = Duration::from_secs(60 * 60); + +/// 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) +} + /// Spawn the background idle-reembed loop. /// Runs forever, checking device state every 10 seconds. pub fn spawn_idle_reembed_loop(state: AppState) { @@ -21,10 +80,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 +98,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 +112,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 +126,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 +136,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,9 +151,42 @@ 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) { + continue; + } + + // 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", @@ -98,7 +195,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 +205,88 @@ 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. + let handle = tokio::task::spawn_blocking(move || { + // 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(); + + // Always drop the active flag and signal completion, even on panic, + // so the UI doesn't get stuck in an "active" state forever. if let Ok(mut st) = state_clone.idle_reembed_state.lock() { st.active = false; } - // Signal completion. + 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"); + } + }); + + // Watcher: log if the spawn_blocking task itself fails to join + // (panic that escaped the catch_unwind, or runtime cancellation). + 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 + ); + } + } + running_flag.store(false, Ordering::Relaxed); }); } }); @@ -185,9 +338,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 +364,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 +394,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..7c831de6 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::{load_user_settings, save_user_settings}; 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,88 @@ 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 mut settings = load_user_settings(&state); + settings.label_index_backend = backend.as_str().to_string(); + save_user_settings(&state, &settings); + + 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..cffc1cdb 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; diff --git a/crates/skill-daemon/src/routes/settings.rs b/crates/skill-daemon/src/routes/settings.rs index fa35c7cb..45dcb4c3 100644 --- a/crates/skill-daemon/src/routes/settings.rs +++ b/crates/skill-daemon/src/routes/settings.rs @@ -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,14 +769,48 @@ 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(); + settings.text_embedding_backend = embedder.backend().as_str().to_string(); + settings.text_embedding_rlx_device = embedder.rlx_device(); + settings.text_embedding_rlx_max_seq = embedder.rlx_max_seq(); save_user_settings(&state_clone, &settings); } (ok, code) @@ -779,7 +818,13 @@ async fn set_text_embedding_model( .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 +1232,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 +1313,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 +1378,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 +1450,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 "# ), @@ -2153,6 +2255,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 +2264,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: false, + mtp: false, recommended: false, advanced: false, params_b: 1.0, @@ -2175,6 +2279,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 +2288,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_device.rs b/crates/skill-daemon/src/routes/settings_device.rs index 94607b1d..ad61992e 100644 --- a/crates/skill-daemon/src/routes/settings_device.rs +++ b/crates/skill-daemon/src/routes/settings_device.rs @@ -119,14 +119,19 @@ pub(crate) async fn set_openbci_config( 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, })) } @@ -138,6 +143,16 @@ pub(crate) async fn set_device_api_config( let mut settings = load_user_settings(&state); settings.device_api = config.clone(); save_user_settings(&state, &settings); + 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; diff --git a/crates/skill-daemon/src/routes/settings_exg.rs b/crates/skill-daemon/src/routes/settings_exg.rs index 9022fe15..3054feb1 100644 --- a/crates/skill-daemon/src/routes/settings_exg.rs +++ b/crates/skill-daemon/src/routes/settings_exg.rs @@ -78,6 +78,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 +96,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 { @@ -276,130 +280,154 @@ 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 n_ch = samples.len(); - 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}"); + // 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(&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], ); - 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 { + tracing::warn!( + "[reembed] skipping all {n_ch}-channel epochs after {CONSECUTIVE_FAIL_LIMIT} consecutive failures" + ); + skip_ch_counts.insert(n_ch); + } } + + total_done += 1; } - total_done += 1; - } + let _ = conn.execute_batch("COMMIT"); - 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, + }), + }); - // 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)); + } + } - // Throttle between batches to reduce contention with other daemon tasks. - if throttle_ms > 0 { - std::thread::sleep(std::time::Duration::from_millis(throttle_ms)); + remaining_epochs = next_remaining; + } + + if !remaining_epochs.is_empty() { + if total_failed == 0 { + let (row_id, ts_ms) = remaining_epochs[0]; + let ts_secs = skill_data::util::epoch_ts_to_unix(ts_ms) as f64; + tracing::warn!( + "[reembed] first empty extract at ts={ts_secs:.1}s (row_id={row_id}, epoch_samples={epoch_samples})", + ); } + 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 ); } @@ -504,10 +532,21 @@ 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; + } let Ok(file) = std::fs::File::open(csv_path) else { continue; }; @@ -534,13 +573,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 +607,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 +661,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, 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 { @@ -1280,6 +1337,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 +1592,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_llm_runtime.rs b/crates/skill-daemon/src/routes/settings_llm_runtime.rs index ddbbe9d1..2c23d102 100644 --- a/crates/skill-daemon/src/routes/settings_llm_runtime.rs +++ b/crates/skill-daemon/src/routes/settings_llm_runtime.rs @@ -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, @@ -984,6 +986,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 +995,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: false, + mtp: false, recommended: false, advanced: false, params_b: 1.0, @@ -1035,6 +1039,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 +1048,7 @@ mod tests { family_desc: String::new(), tags: vec![], is_mmproj: false, + mtp: false, recommended: false, advanced: false, params_b: 1.0, @@ -1057,6 +1063,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 +1072,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_ui.rs b/crates/skill-daemon/src/routes/settings_ui.rs index 58943aba..70720858 100644 --- a/crates/skill-daemon/src/routes/settings_ui.rs +++ b/crates/skill-daemon/src/routes/settings_ui.rs @@ -235,18 +235,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})) } 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..24f5509a 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 ────────────────────────────────────────────────────── @@ -270,8 +270,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 +314,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 +534,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..084af6f2 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,24 +625,36 @@ 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.metrics_wtr { + if let Some(w) = self.ppg_wtr.take() { let _ = w.close(); } - if let Some(w) = self.imu_wtr { + if let Some(w) = self.metrics_wtr.take() { let _ = w.close(); } - if let Some(w) = self.fnirs_wtr { + if let Some(w) = self.imu_wtr.take() { let _ = w.close(); } + if let Some(w) = self.fnirs_wtr.take() { + let _ = w.close(); + } + } +} + +impl Drop for ParquetState { + fn drop(&mut self) { + self.close(); } } @@ -712,6 +731,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/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..1635c4cf 100644 --- a/crates/skill-eeg/src/eeg_bands.rs +++ b/crates/skill-eeg/src/eeg_bands.rs @@ -208,6 +208,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 +293,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). @@ -828,6 +844,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 +856,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 +866,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 +996,7 @@ impl BandAnalyzer { sample_entropy: sample_entropy_val, pac_theta_gamma, laterality_index, + echt, headache_index, migraine_index, consciousness_lzc, @@ -1001,6 +1021,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_model_config.rs b/crates/skill-eeg/src/eeg_model_config.rs index 765f83a6..2dac723c 100644 --- a/crates/skill-eeg/src/eeg_model_config.rs +++ b/crates/skill-eeg/src/eeg_model_config.rs @@ -348,6 +348,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, diff --git a/crates/skill-exg/Cargo.toml b/crates/skill-exg/Cargo.toml index 96dc4495..d0f8f538 100644 --- a/crates/skill-exg/Cargo.toml +++ b/crates/skill-exg/Cargo.toml @@ -17,6 +17,7 @@ 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" diff --git a/crates/skill-exg/src/lib.rs b/crates/skill-exg/src/lib.rs index bf504de1..31a1897b 100644 --- a/crates/skill-exg/src/lib.rs +++ b/crates/skill-exg/src/lib.rs @@ -748,6 +748,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 +777,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 +793,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 +801,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 +824,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 +849,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 +917,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 +1130,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-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..4df4d315 100644 --- a/crates/skill-iroh/Cargo.toml +++ b/crates/skill-iroh/Cargo.toml @@ -10,7 +10,8 @@ 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"] } 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..c024984f --- /dev/null +++ b/crates/skill-label-index/build.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Link system OpenBLAS on Linux when building with `turboquant-index`. +//! +//! `turbovec` uses ndarray BLAS (CBLAS) on Linux/macOS. macOS uses `accelerate-src` +//! from `Cargo.toml`; Linux CI installs `libopenblas-dev` and needs explicit link +//! paths below (Debian/Ubuntu alternatives layout). Windows turbovec builds use +//! pure-Rust/faer paths in the upstream crate (no OpenBLAS). + +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux") { + return; + } + if std::env::var_os("CARGO_FEATURE_TURBOQUANT_INDEX").is_none() { + 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-lib=openblas"); +} 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..3b663e1a 100644 --- a/crates/skill-llm/Cargo.toml +++ b/crates/skill-llm/Cargo.toml @@ -13,6 +13,8 @@ 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"] +llm-rlx = ["llm", "dep:rlx"] +llm-rlx-metal = ["llm-rlx", "rlx?/metal", "rlx?/blas-accelerate"] [dependencies] anyhow = { workspace = true } @@ -33,16 +35,17 @@ 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", "models", "gguf"] } [build-dependencies] -llama-cpp-4 = { version = "0.2.50", optional = true, default-features = false, features = ["ggml", "native"] } +llama-cpp-4 = { version = "0.3.0", 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"] } +llama-cpp-4 = { version = "0.3.0", 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"] } +llama-cpp-4 = { version = "0.3.0", optional = true, default-features = false, features = ["ggml", "vulkan", "native", "mtmd"] } [dev-dependencies] tempfile = "3" diff --git a/crates/skill-llm/src/bin/bench_qwen_runtimes.rs b/crates/skill-llm/src/bin/bench_qwen_runtimes.rs new file mode 100644 index 00000000..fdc1fe83 --- /dev/null +++ b/crates/skill-llm/src/bin/bench_qwen_runtimes.rs @@ -0,0 +1,596 @@ +// SPDX-License-Identifier: GPL-3.0-only +//! Benchmark Qwen GGUF generation through llama.cpp and RLX. +//! +//! Example: +//! ```sh +//! cargo run --release -p skill-llm --features llm-rlx-metal \ +//! --bin bench_qwen_runtimes -- \ +//! --model /path/to/Qwen3-0.6B-Q4_K_M.gguf --runtime all --max-tokens 64 +//! ``` + +#[cfg(feature = "llm")] +fn main() -> anyhow::Result<()> { + bench::main() +} + +#[cfg(not(feature = "llm"))] +fn main() { + eprintln!("bench_qwen_runtimes requires skill-llm feature: llm"); + std::process::exit(2); +} + +#[cfg(feature = "llm")] +mod bench { + use anyhow::{anyhow, Context, Result}; + use llama_cpp_4::{ + context::params::{LlamaContextParams, LlamaContextType}, + llama_backend::LlamaBackend, + llama_batch::LlamaBatch, + model::{params::LlamaModelParams, AddBos, LlamaModel}, + mtp::MtpSession, + sampling::LlamaSampler, + }; + use std::{num::NonZeroU32, path::PathBuf, time::Instant}; + + #[derive(Debug, Clone)] + struct Args { + model: PathBuf, + prompt: String, + runtime: Runtime, + max_tokens: usize, + ctx_size: u32, + n_gpu_layers: u32, + #[cfg_attr(not(feature = "llm-rlx"), allow(dead_code))] + rlx_device: String, + #[cfg_attr(not(feature = "llm-rlx"), allow(dead_code))] + rlx_max_seq: usize, + mtp_draft_count: u32, + mtp_n_rs_seq: u32, + warmup: usize, + runs: usize, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Runtime { + All, + Llama, + LlamaMtp, + Rlx, + } + + #[derive(Debug, Clone)] + struct RunStats { + runtime: &'static str, + load_ms: f64, + prompt_tokens: usize, + completion_tokens: usize, + prefill_ms: Option, + decode_ms: f64, + total_ms: f64, + mtp_rounds: Option, + mtp_drafts: Option, + mtp_accepted: Option, + } + + pub(super) fn main() -> Result<()> { + let args = parse_args()?; + println!("# Qwen runtime benchmark"); + println!("model: {}", args.model.display()); + println!("prompt chars: {}", args.prompt.chars().count()); + println!( + "max_tokens: {} - ctx_size: {} - runs: {} - warmup: {}", + args.max_tokens, args.ctx_size, args.runs, args.warmup + ); + + let mut results = Vec::new(); + + let llama_needed = matches!( + args.runtime, + Runtime::All | Runtime::Llama | Runtime::LlamaMtp | Runtime::Rlx + ); + let (mut backend, model, prompt_ids) = if llama_needed { + let mut backend = LlamaBackend::init().context("initialising llama backend")?; + backend.void_logs(); + let model_params = LlamaModelParams::default().with_n_gpu_layers(args.n_gpu_layers); + let model = LlamaModel::load_from_file(&backend, &args.model, &model_params) + .with_context(|| format!("loading llama model {}", args.model.display()))?; + let prompt_ids = model + .str_to_token(&args.prompt, AddBos::Always) + .map_err(|e| anyhow!("tokenizing prompt with llama tokenizer: {e}"))?; + (Some(backend), Some(model), prompt_ids) + } else { + (None, None, Vec::new()) + }; + + if matches!(args.runtime, Runtime::All | Runtime::Llama) { + let backend = backend.as_mut().expect("backend loaded"); + let model = model.as_ref().expect("model loaded"); + let load_start = Instant::now(); + let mut llama = LlamaBench::new(backend, model, &args)?; + let load_ms = load_start.elapsed().as_secs_f64() * 1000.0; + for _ in 0..args.warmup { + let _ = llama.run(&prompt_ids, args.max_tokens)?; + } + for _ in 0..args.runs { + let mut s = llama.run(&prompt_ids, args.max_tokens)?; + s.load_ms = load_ms; + results.push(s); + } + } + + if matches!(args.runtime, Runtime::All | Runtime::LlamaMtp) { + let backend = backend.as_mut().expect("backend loaded"); + let model = model.as_ref().expect("model loaded"); + let load_start = Instant::now(); + let mut mtp = LlamaMtpBench::new(backend, model, &args)?; + let load_ms = load_start.elapsed().as_secs_f64() * 1000.0; + for _ in 0..args.warmup { + let _ = mtp.run(&prompt_ids, args.max_tokens)?; + } + for _ in 0..args.runs { + let mut s = mtp.run(&prompt_ids, args.max_tokens)?; + s.load_ms = load_ms; + results.push(s); + } + } + + #[cfg(not(feature = "llm-rlx"))] + if matches!(args.runtime, Runtime::Rlx) { + return Err(anyhow!("--runtime rlx requires feature llm-rlx")); + } + + #[cfg(feature = "llm-rlx")] + if matches!(args.runtime, Runtime::All | Runtime::Rlx) { + let prompt_u32 = prompt_ids + .iter() + .map(|tok| u32::try_from(tok.0).context("negative llama token id cannot be passed to RLX")) + .collect::>>()?; + let load_start = Instant::now(); + let mut rlx = RlxBench::new(&args)?; + let load_ms = load_start.elapsed().as_secs_f64() * 1000.0; + for _ in 0..args.warmup { + let _ = rlx.run(&prompt_u32, args.max_tokens)?; + } + for _ in 0..args.runs { + let mut s = rlx.run(&prompt_u32, args.max_tokens)?; + s.load_ms = load_ms; + results.push(s); + } + } + + print_results(&results); + Ok(()) + } + + struct LlamaBench<'a> { + model: &'a LlamaModel, + ctx: llama_cpp_4::context::LlamaContext<'a>, + } + + impl<'a> LlamaBench<'a> { + fn new(backend: &mut LlamaBackend, model: &'a LlamaModel, args: &Args) -> Result { + let ctx_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(args.ctx_size)) + .with_n_batch(args.ctx_size.min(4096)) + .with_n_ubatch(args.ctx_size.min(2048)) + .with_n_threads(-1) + .with_n_threads_batch(-1) + .with_flash_attention(true) + .with_offload_kqv(true); + let ctx = model + .new_context(backend, ctx_params) + .context("creating llama context")?; + Ok(Self { model, ctx }) + } + + fn run(&mut self, prompt: &[llama_cpp_4::token::LlamaToken], max_tokens: usize) -> Result { + self.ctx.clear_kv_cache(); + let t_total = Instant::now(); + let t_prefill = Instant::now(); + let n_batch = self.ctx.n_batch() as usize; + let mut i = 0usize; + while i < prompt.len() { + let end = (i + n_batch).min(prompt.len()); + let mut batch = LlamaBatch::new(end - i, 1); + for (j, &token) in prompt.iter().enumerate().take(end).skip(i) { + batch + .add(token, j as i32, &[0], j == prompt.len() - 1) + .map_err(|_| anyhow!("llama prefill batch overflow"))?; + } + self.ctx.decode(&mut batch).context("llama prefill decode")?; + i = end; + } + let prefill_ms = t_prefill.elapsed().as_secs_f64() * 1000.0; + + let t_decode = Instant::now(); + let mut sampler = LlamaSampler::chain_simple([ + LlamaSampler::top_k(40), + LlamaSampler::top_p(0.9, 1), + LlamaSampler::temp(0.0), + LlamaSampler::dist(0xDEAD_BEEF), + ]); + let mut n_gen = 0usize; + let mut pos = prompt.len(); + while n_gen < max_tokens && pos < self.ctx.n_ctx() as usize { + let token = sampler.sample(&self.ctx, -1); + sampler.accept(token); + if self.model.is_eog_token(token) { + break; + } + let mut batch = LlamaBatch::new(1, 1); + batch + .add(token, pos as i32, &[0], true) + .map_err(|_| anyhow!("llama decode batch overflow"))?; + self.ctx.decode(&mut batch).context("llama decode")?; + pos += 1; + n_gen += 1; + } + let decode_ms = t_decode.elapsed().as_secs_f64() * 1000.0; + Ok(RunStats { + runtime: "llama.cpp", + load_ms: 0.0, + prompt_tokens: prompt.len(), + completion_tokens: n_gen, + prefill_ms: Some(prefill_ms), + decode_ms, + total_ms: t_total.elapsed().as_secs_f64() * 1000.0, + mtp_rounds: None, + mtp_drafts: None, + mtp_accepted: None, + }) + } + } + + struct LlamaMtpBench<'a> { + model: &'a LlamaModel, + target_ctx: llama_cpp_4::context::LlamaContext<'a>, + draft_ctx: llama_cpp_4::context::LlamaContext<'a>, + n_draft_max: i32, + } + + impl<'a> LlamaMtpBench<'a> { + fn new(backend: &mut LlamaBackend, model: &'a LlamaModel, args: &Args) -> Result { + let n_rs_seq = args.mtp_n_rs_seq.max(args.mtp_draft_count.saturating_add(1)).max(4); + let target_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(args.ctx_size)) + .with_n_batch(args.ctx_size.min(4096)) + .with_n_ubatch(args.ctx_size.min(2048)) + .with_n_threads(-1) + .with_n_threads_batch(-1) + .with_flash_attention(true) + .with_offload_kqv(true) + .with_ctx_type(LlamaContextType::Default) + .with_n_rs_seq(n_rs_seq); + let target_ctx = model + .new_context(backend, target_params) + .context("creating llama MTP target context")?; + let draft_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(args.ctx_size)) + .with_n_threads(-1) + .with_n_threads_batch(-1) + .with_flash_attention(true) + .with_offload_kqv(true) + .with_ctx_type(LlamaContextType::Mtp) + .with_n_rs_seq(n_rs_seq); + let draft_ctx = model + .new_context(backend, draft_params) + .context("creating llama MTP draft context")?; + Ok(Self { + model, + target_ctx, + draft_ctx, + n_draft_max: args.mtp_draft_count as i32, + }) + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn run(&mut self, prompt: &[llama_cpp_4::token::LlamaToken], max_tokens: usize) -> Result { + self.target_ctx.clear_kv_cache(); + self.draft_ctx.clear_kv_cache(); + let t_total = Instant::now(); + + let mut session = MtpSession::new(&self.target_ctx, &self.draft_ctx, 1, self.n_draft_max) + .context("creating MTP session")?; + + let t_prefill = Instant::now(); + let mut prefill = LlamaBatch::new(prompt.len(), 1); + for (i, &token) in prompt.iter().enumerate() { + prefill + .add(token, i as i32, &[0], i + 1 == prompt.len()) + .map_err(|_| anyhow!("llama MTP prefill batch overflow"))?; + } + self.target_ctx + .decode(&mut prefill) + .context("llama MTP prefill decode")?; + session.process(&prefill).context("MTP process(prefill)")?; + session.begin(0, prompt).context("MTP begin")?; + let prefill_ms = t_prefill.elapsed().as_secs_f64() * 1000.0; + + let t_decode = Instant::now(); + let mut sampler = LlamaSampler::chain_simple([ + LlamaSampler::top_k(40), + LlamaSampler::top_p(0.9, 1), + LlamaSampler::temp(0.0), + LlamaSampler::dist(0xDEAD_BEEF), + ]); + + let first_token = sampler.sample(&self.target_ctx, prefill.n_tokens() - 1); + sampler.accept(first_token); + if self.model.is_eog_token(first_token) { + let decode_ms = t_decode.elapsed().as_secs_f64() * 1000.0; + return Ok(RunStats { + runtime: "llama.cpp-mtp", + load_ms: 0.0, + prompt_tokens: prompt.len(), + completion_tokens: 0, + prefill_ms: Some(prefill_ms), + decode_ms, + total_ms: t_total.elapsed().as_secs_f64() * 1000.0, + mtp_rounds: Some(0), + mtp_drafts: Some(0), + mtp_accepted: Some(0), + }); + } + + let mut last_token = first_token; + let mut n_past = prompt.len() as i32; + let mut n_gen = 1usize; + let mut rounds = 0u64; + let mut drafts_total = 0u64; + let mut accepted_total = 0u64; + let verify_cap = (self.n_draft_max as usize + 1).max(self.target_ctx.n_batch() as usize); + let mut verify = LlamaBatch::new(verify_cap, 1); + + 'gen: while n_gen < max_tokens && (n_past as usize) < self.target_ctx.n_ctx() as usize { + let drafts = session.draft(0, n_past, last_token).context("MTP draft")?; + rounds += 1; + drafts_total += drafts.len() as u64; + + verify.clear(); + verify + .add(last_token, n_past, &[0], true) + .map_err(|_| anyhow!("llama MTP verify batch overflow"))?; + for (i, d) in drafts.iter().enumerate() { + verify + .add(*d, n_past + 1 + i as i32, &[0], true) + .map_err(|_| anyhow!("llama MTP verify batch overflow"))?; + } + let n_verify = verify.n_tokens(); + + self.draft_ctx + .clear_kv_cache_seq(Some(0), Some(n_past as u32), None) + .context("MTP draft KV rollback")?; + self.target_ctx.decode(&mut verify).context("llama MTP verify decode")?; + session.process(&verify).context("MTP process(verify)")?; + + let mut n_accepted = 0usize; + let mut next_token = sampler.sample(&self.target_ctx, 0); + sampler.accept(next_token); + for (i, draft) in drafts.iter().enumerate() { + if next_token == *draft { + n_accepted = i + 1; + if i + 1 < n_verify as usize { + next_token = sampler.sample(&self.target_ctx, (i + 1) as i32); + sampler.accept(next_token); + } + } else { + break; + } + } + accepted_total += n_accepted as u64; + + let new_n_past = n_past + 1 + n_accepted as i32; + if n_accepted < drafts.len() { + if !self + .target_ctx + .clear_kv_cache_seq(Some(0), Some(new_n_past as u32), None) + .context("MTP target KV rollback")? + { + return Err(anyhow!("MTP target KV rollback rejected")); + } + if !self + .draft_ctx + .clear_kv_cache_seq(Some(0), Some(new_n_past as u32), None) + .context("MTP draft KV rollback")? + { + return Err(anyhow!("MTP draft KV rollback rejected")); + } + } + session.accept(0, n_accepted as u16).context("MTP accept")?; + + for d in drafts.iter().take(n_accepted) { + if self.model.is_eog_token(*d) { + break 'gen; + } + n_gen += 1; + if n_gen >= max_tokens { + break 'gen; + } + } + if self.model.is_eog_token(next_token) { + break; + } + n_gen += 1; + last_token = next_token; + n_past = new_n_past; + } + + let decode_ms = t_decode.elapsed().as_secs_f64() * 1000.0; + Ok(RunStats { + runtime: "llama.cpp-mtp", + load_ms: 0.0, + prompt_tokens: prompt.len(), + completion_tokens: n_gen, + prefill_ms: Some(prefill_ms), + decode_ms, + total_ms: t_total.elapsed().as_secs_f64() * 1000.0, + mtp_rounds: Some(rounds), + mtp_drafts: Some(drafts_total), + mtp_accepted: Some(accepted_total), + }) + } + } + + #[cfg(feature = "llm-rlx")] + struct RlxBench { + runner: rlx::run::Qwen3Runner, + } + + #[cfg(feature = "llm-rlx")] + impl RlxBench { + fn new(args: &Args) -> Result { + let device = parse_rlx_device(&args.rlx_device)?; + let runner = rlx::run::Qwen3Runner::builder() + .weights(&args.model) + .device(device) + .max_seq(args.rlx_max_seq) + .stream(false) + .build() + .context("building RLX Qwen runner")?; + Ok(Self { runner }) + } + + fn run(&mut self, prompt: &[u32], max_tokens: usize) -> Result { + let t = Instant::now(); + let out = self + .runner + .generate(prompt, max_tokens, |_| {}) + .context("RLX generate")?; + let total_ms = t.elapsed().as_secs_f64() * 1000.0; + Ok(RunStats { + runtime: "rlx", + load_ms: 0.0, + prompt_tokens: prompt.len(), + completion_tokens: out.len(), + prefill_ms: None, + decode_ms: total_ms, + total_ms, + mtp_rounds: None, + mtp_drafts: None, + mtp_accepted: None, + }) + } + } + + fn parse_args() -> Result { + let mut model = None; + let mut prompt = "Write one concise paragraph about brain-computer interfaces.".to_string(); + let mut runtime = Runtime::All; + let mut max_tokens = 64usize; + let mut ctx_size = 2048u32; + let mut n_gpu_layers = u32::MAX; + let mut rlx_device = if cfg!(target_os = "macos") { "metal" } else { "cpu" }.to_string(); + let mut rlx_max_seq = 128usize; + let mut mtp_draft_count = 1u32; + let mut mtp_n_rs_seq = 0u32; + let mut warmup = 1usize; + let mut runs = 3usize; + + let args: Vec = std::env::args().skip(1).collect(); + let mut i = 0usize; + while i < args.len() { + let key = &args[i]; + let mut value = || -> Result { + i += 1; + args.get(i).cloned().ok_or_else(|| anyhow!("missing value for {key}")) + }; + match key.as_str() { + "--model" => model = Some(value()?.into()), + "--prompt" => prompt = value()?, + "--runtime" => { + runtime = match value()?.as_str() { + "all" => Runtime::All, + "llama" | "llama.cpp" => Runtime::Llama, + "mtp" | "llama-mtp" | "llama.cpp-mtp" => Runtime::LlamaMtp, + "rlx" => Runtime::Rlx, + other => return Err(anyhow!("--runtime must be all|llama|mtp|rlx, got {other}")), + }; + } + "--max-tokens" => max_tokens = value()?.parse()?, + "--ctx-size" => ctx_size = value()?.parse()?, + "--n-gpu-layers" => { + let raw: i64 = value()?.parse()?; + n_gpu_layers = if raw < 0 { u32::MAX } else { raw as u32 }; + } + "--rlx-device" => rlx_device = value()?, + "--rlx-max-seq" => rlx_max_seq = value()?.parse()?, + "--mtp-draft-count" => mtp_draft_count = value()?.parse()?, + "--mtp-n-rs-seq" => mtp_n_rs_seq = value()?.parse()?, + "--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: model.ok_or_else(|| anyhow!("--model /path/to/model.gguf is required"))?, + prompt, + runtime, + max_tokens, + ctx_size, + n_gpu_layers, + rlx_device, + rlx_max_seq, + mtp_draft_count, + mtp_n_rs_seq, + warmup, + runs, + }) + } + + #[cfg(feature = "llm-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}'")), + } + } + + fn print_results(results: &[RunStats]) { + println!(); + println!("| runtime | load ms | prompt tok | gen tok | prefill ms | decode/total ms | tok/s | mtp accept |"); + println!("|---|---:|---:|---:|---:|---:|---:|---:|"); + for r in results { + let tok_s = if r.decode_ms > 0.0 { + r.completion_tokens as f64 / (r.decode_ms / 1000.0) + } else { + 0.0 + }; + let prefill = r.prefill_ms.map(|v| format!("{v:.1}")).unwrap_or_else(|| "n/a".into()); + let mtp_accept = match (r.mtp_rounds, r.mtp_drafts, r.mtp_accepted) { + (Some(rounds), Some(drafts), Some(accepted)) if drafts > 0 => { + format!( + "{accepted}/{drafts} ({:.1}%, {rounds} rounds)", + 100.0 * accepted as f64 / drafts as f64 + ) + } + (Some(rounds), Some(_), Some(_)) => format!("0/0 (0.0%, {rounds} rounds)"), + _ => "n/a".into(), + }; + println!( + "| {} | {:.1} | {} | {} | {} | {:.1} | {:.2} | {} |", + r.runtime, r.load_ms, r.prompt_tokens, r.completion_tokens, prefill, r.total_ms, tok_s, mtp_accept + ); + } + } + + fn print_usage() { + eprintln!( + "Usage: bench_qwen_runtimes --model model.gguf [--runtime all|llama|mtp|rlx] \ + [--prompt TEXT] [--max-tokens N] [--ctx-size N] [--n-gpu-layers -1] \ + [--mtp-draft-count 1] [--mtp-n-rs-seq 0] [--rlx-device metal] \ + [--rlx-max-seq 128] [--warmup 1] [--runs 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..3f292708 100644 --- a/crates/skill-llm/src/config.rs +++ b/crates/skill-llm/src/config.rs @@ -11,6 +11,14 @@ 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 { + #[default] + LlamaCpp, + Rlx, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct LlmConfig { @@ -23,6 +31,11 @@ pub struct LlmConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub model_path: Option, + /// Inference runtime. Defaults to llama.cpp; `rlx` is experimental and + /// requires building with the `llm-rlx` or `llm-rlx-metal` feature. + #[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)] @@ -154,6 +167,41 @@ pub struct LlmConfig { /// 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::LlamaCpp, 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 index 102e6e25..c61a6640 100644 --- a/crates/skill-llm/src/engine/actor.rs +++ b/crates/skill-llm/src/engine/actor.rs @@ -14,17 +14,17 @@ use std::{ use serde_json::{json, Value}; use llama_cpp_4::{ - context::params::{LlamaContextParams, LlamaPoolingType}, + context::params::{LlamaContextParams, LlamaContextType, LlamaPoolingType}, llama_backend::LlamaBackend, llama_batch::LlamaBatch, model::{params::LlamaModelParams, AddBos, LlamaModel}, quantize::GgmlType, }; -use super::generation::{run_generation, GpuMemoryGuard}; +use super::generation::{run_generation, run_generation_mtp, GpuMemoryGuard}; use super::logging::{LlmLogBuffer, LlmLogFile}; use super::protocol::{InferRequest, InferToken}; -use crate::config::LlmConfig; +use crate::config::{LlmConfig, LlmInferenceRuntime}; use crate::event::LlmEventEmitter; /// Map a human-readable KV-cache type tag from [`LlmConfig`] to a [`GgmlType`]. @@ -202,7 +202,20 @@ pub(super) fn run_actor( // 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() + // MTP: when the active model is catalog-flagged MTP-capable AND the user + // has dialed in a draft count, the target context must be built with + // `with_n_rs_seq(n)` so partial KV rollback after rejected drafts + // succeeds on hybrid/recurrent models (Qwen3.6 M-RoPE). + let mtp_active = config.mtp_capable && config.mtp_draft_count > 0; + let mtp_n_rs_seq = if !mtp_active { + 0 + } else if config.mtp_n_rs_seq > 0 { + config.mtp_n_rs_seq + } else { + config.mtp_draft_count.saturating_add(1).max(4) + }; + + let mut ctx_params = LlamaContextParams::default() .with_n_ctx(ctx_size) .with_n_batch(n_batch) .with_n_ubatch(n_ubatch) @@ -213,6 +226,11 @@ pub(super) fn run_actor( .with_cache_type_k(kv_type_k) .with_cache_type_v(kv_type_v) .with_attn_rot_disabled(config.attn_rot_disabled); + if mtp_active { + ctx_params = ctx_params + .with_ctx_type(LlamaContextType::Default) + .with_n_rs_seq(mtp_n_rs_seq); + } let mut ctx = match model.new_context(backend, ctx_params) { Ok(c) => c, @@ -491,6 +509,86 @@ pub(super) fn run_actor( ); } + // ── MTP smoke validation (load-time) ──────────────────────────────────── + // When MTP is configured, build a throwaway draft context to verify the + // GGUF has MTP heads compiled in. Each request rebuilds the draft for + // its own KV state — this is purely an early-signal check that + // downgrades the per-request dispatch to the standard path on failure. + let mtp_use = mtp_active && { + let draft_params = LlamaContextParams::default() + .with_n_ctx(ctx_size) + .with_ctx_type(LlamaContextType::Mtp) + .with_n_rs_seq(mtp_n_rs_seq); + match model.new_context(backend, draft_params) { + Ok(draft_ctx) => { + llm_info!( + &app, + &log_buf, + log_file, + "[mtp] draft heads present — n_draft_max={}, n_rs_seq={}, draft_n_ctx={}", + config.mtp_draft_count, + mtp_n_rs_seq, + draft_ctx.n_ctx() + ); + drop(draft_ctx); + true + } + Err(e) => { + llm_warn!( + &app, + &log_buf, + log_file, + "[mtp] draft heads missing — disabling MTP for this session: {e}. \ + Try froggeric/Qwen3.6-27B-MTP-GGUF (e.g. Qwen3.6-27B-Q4_K_M-mtp.gguf)" + ); + false + } + } + }; + + #[cfg(feature = "llm-rlx")] + let mut rlx_runner = if matches!(config.runtime, LlmInferenceRuntime::Rlx) { + llm_info!( + &app, + &log_buf, + log_file, + "initialising experimental RLX runtime (device={}, max_seq={})", + config.rlx_device, + config.rlx_max_seq + ); + match super::rlx_backend::RlxTextRunner::load(&model_path, &config) { + Ok(runner) => { + llm_info!(&app, &log_buf, log_file, "RLX runtime ready"); + Some(runner) + } + Err(e) => { + llm_error!(&app, &log_buf, log_file, "failed to initialise RLX runtime: {e}"); + app.emit_event( + "llm:status", + json!({"status":"stopped","error":format!("failed to initialise RLX runtime: {e}")}), + ); + return; + } + } + } else { + None + }; + + #[cfg(not(feature = "llm-rlx"))] + if matches!(config.runtime, LlmInferenceRuntime::Rlx) { + llm_error!( + &app, + &log_buf, + log_file, + "RLX runtime requested but this build does not include the llm-rlx feature" + ); + app.emit_event( + "llm:status", + json!({"status":"stopped","error":"RLX runtime requested but this build lacks llm-rlx"}), + ); + return; + } + // 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("?"); @@ -505,7 +603,13 @@ pub(super) fn run_actor( ); app.emit_event( "llm:status", - json!({"status":"running","model":model_file,"supports_vision":vision_loaded,"supports_tools":true}), + json!({ + "status":"running", + "model":model_file, + "runtime": format!("{:?}", config.runtime), + "supports_vision": vision_loaded && !matches!(config.runtime, LlmInferenceRuntime::Rlx), + "supports_tools": true + }), ); // ── event loop ── @@ -682,7 +786,7 @@ pub(super) fn run_actor( 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() + let mut new_ctx_params = LlamaContextParams::default() .with_n_ctx(NonZeroU32::new(new_ctx)) .with_n_batch(resize_n_batch) .with_n_ubatch(resize_n_ubatch) @@ -693,6 +797,11 @@ pub(super) fn run_actor( .with_cache_type_k(kv_type_k) .with_cache_type_v(kv_type_v) .with_attn_rot_disabled(config.attn_rot_disabled); + if mtp_active { + new_ctx_params = new_ctx_params + .with_ctx_type(LlamaContextType::Default) + .with_n_rs_seq(mtp_n_rs_seq); + } match model.new_context(backend, new_ctx_params) { Ok(new_c) => { @@ -740,6 +849,20 @@ pub(super) fn run_actor( }; let Some(prompt) = prompt else { continue }; + #[cfg(feature = "llm-rlx")] + if let Some(runner) = rlx_runner.as_mut() { + if !images.is_empty() { + token_tx + .send(InferToken::Error( + "RLX runtime currently supports text-only generation".into(), + )) + .ok(); + continue; + } + runner.generate(&model, &prompt, params, token_tx); + continue; + } + #[cfg(feature = "llm-mtmd")] if use_mtmd { if let Some(ref mc) = mtmd_ctx { @@ -750,9 +873,26 @@ pub(super) fn run_actor( } } - run_generation( - &model, &mut ctx, &app, &log_buf, log_file, prompt, params, token_tx, gpu_guard, - ); + if mtp_use && images.is_empty() { + run_generation_mtp( + &model, + &mut ctx, + backend, + &app, + &log_buf, + log_file, + prompt, + params, + token_tx, + gpu_guard, + config.mtp_draft_count as i32, + mtp_n_rs_seq, + ); + } else { + run_generation( + &model, &mut ctx, &app, &log_buf, log_file, prompt, params, token_tx, gpu_guard, + ); + } } InferRequest::Complete { @@ -767,9 +907,31 @@ pub(super) fn run_actor( "completion request — max_tokens={}", params.max_tokens ); - run_generation( - &model, &mut ctx, &app, &log_buf, log_file, prompt, params, token_tx, gpu_guard, - ); + #[cfg(feature = "llm-rlx")] + if let Some(runner) = rlx_runner.as_mut() { + runner.generate(&model, &prompt, params, token_tx); + continue; + } + if mtp_use { + run_generation_mtp( + &model, + &mut ctx, + backend, + &app, + &log_buf, + log_file, + prompt, + params, + token_tx, + gpu_guard, + config.mtp_draft_count as i32, + mtp_n_rs_seq, + ); + } else { + run_generation( + &model, &mut ctx, &app, &log_buf, log_file, prompt, params, token_tx, gpu_guard, + ); + } } InferRequest::Embed { inputs, result_tx } => { diff --git a/crates/skill-llm/src/engine/generation.rs b/crates/skill-llm/src/engine/generation.rs index 9e59f843..9b2b26ea 100644 --- a/crates/skill-llm/src/engine/generation.rs +++ b/crates/skill-llm/src/engine/generation.rs @@ -4,11 +4,19 @@ use tokio::sync::mpsc::UnboundedSender; -use llama_cpp_4::{llama_batch::LlamaBatch, model::AddBos}; +use llama_cpp_4::{ + context::params::{LlamaContextParams, LlamaContextType}, + llama_backend::LlamaBackend, + llama_batch::LlamaBatch, + model::AddBos, + mtp::MtpSession, + sampling::LlamaSampler, +}; use super::logging::{LlmLogBuffer, LlmLogFile}; use super::protocol::{GenParams, InferToken}; use super::sampling::run_sampling_loop; +use super::sampling_mtp::run_sampling_loop_mtp; use crate::event::LlmEventEmitter; /// GPU memory safety thresholds (configurable via LlmConfig). @@ -285,3 +293,198 @@ pub(super) fn run_generation_multimodal( model, ctx, app, log_buf, log_file, ¶ms, token_tx, n_prompt, gpu_guard, ); } + +// ── MTP (Multi-Token Prediction) generation ─────────────────────────────────── + +/// Text-only generation with MTP speculative decoding. +/// +/// Differences vs `run_generation`: +/// * Prefill enables pre-norm embeddings for MTP while only keeping logits on +/// the final prompt position used for the first sampled token. +/// * Builds a per-request `LlamaContextType::Mtp` draft context and +/// `MtpSession` from `model + backend`. Both are dropped before this +/// function returns — the cost is one set of recurrent-state allocations +/// per request, which is acceptable for the win MTP offers (per the +/// v0.2.53 fork benchmark: +6.2% on Q4_K_M with `n_draft_max=1`). +/// * Hands off to `run_sampling_loop_mtp` for the draft/verify loop. +/// +/// Vision (mtmd) requests do NOT route here — they stay on the +/// non-speculative `run_generation_multimodal` path. Combining MTP with +/// mtmd is not yet validated upstream. +#[allow(clippy::too_many_arguments)] +pub(super) fn run_generation_mtp( + model: &llama_cpp_4::model::LlamaModel, + target_ctx: &mut llama_cpp_4::context::LlamaContext<'_>, + backend: &LlamaBackend, + app: &dyn LlmEventEmitter, + log_buf: &LlmLogBuffer, + log_file: Option<&LlmLogFile>, + prompt: String, + params: GenParams, + token_tx: UnboundedSender, + gpu_guard: GpuMemoryGuard, + n_draft_max: i32, + n_rs_seq: u32, +) { + target_ctx.clear_kv_cache(); + + 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 = target_ctx.n_ctx() as usize; + + llm_info!( + app, + log_buf, + log_file, + "[mtp] prompt: {n_prompt} tokens, n_draft_max={n_draft_max}, 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; + } + + 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).", + 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; + } + + // Build per-request draft context. n_ctx matches target so KV positions + // line up. n_batch / n_ubatch are intentionally left at the library + // default for the draft side — the draft only ever processes + // `1 + n_draft_max` tokens per round, far below typical batch sizes. + let draft_n_ctx = + std::num::NonZeroU32::new(target_ctx.n_ctx()).unwrap_or_else(|| std::num::NonZeroU32::new(2048).unwrap()); + let draft_params = LlamaContextParams::default() + .with_n_ctx(Some(draft_n_ctx)) + .with_ctx_type(LlamaContextType::Mtp) + .with_n_rs_seq(n_rs_seq); + let mut draft_ctx = match model.new_context(backend, draft_params) { + Ok(c) => c, + Err(e) => { + llm_error!(app, log_buf, log_file, "[mtp] draft context build failed: {e}"); + token_tx + .send(InferToken::Error(format!("MTP draft context build failed: {e}"))) + .ok(); + return; + } + }; + + let mut session = match MtpSession::new(target_ctx, &draft_ctx, 1, n_draft_max) { + Ok(s) => s, + Err(e) => { + llm_error!(app, log_buf, log_file, "[mtp] session init failed: {e}"); + token_tx + .send(InferToken::Error(format!("MTP session init failed: {e}"))) + .ok(); + return; + } + }; + + // ── Prefill: decode the whole prompt in one batch for session.process. + // MTP consumes pre-norm embeddings from the batch; logits are only needed + // on the final prompt token for the first sampled token. + let n_batch = target_ctx.n_batch() as usize; + let prefill_cap = n_prompt.max(n_batch); + let mut prefill = LlamaBatch::new(prefill_cap, 1); + // Single-batch path keeps all prompt positions visible to + // `session.process` in one call. If the prompt exceeds n_batch we grow the + // batch capacity here (the underlying llama_batch_init allocates per + // LlamaBatch::new). + for (i, tok) in tokens.iter().copied().enumerate() { + let logits = mtp_prefill_needs_logits(i, n_prompt); + if prefill.add(tok, i as i32, &[0], logits).is_err() { + token_tx.send(InferToken::Error("prefill batch overflow".into())).ok(); + return; + } + } + if let Err(e) = target_ctx.decode(&mut prefill) { + llm_error!(app, log_buf, log_file, "[mtp] prefill decode failed: {e}"); + token_tx.send(InferToken::Error(format!("prefill decode: {e}"))).ok(); + return; + } + if let Err(e) = session.process(&prefill) { + llm_error!(app, log_buf, log_file, "[mtp] process(prefill) failed: {e}"); + token_tx + .send(InferToken::Error(format!("MTP process(prefill): {e}"))) + .ok(); + return; + } + if let Err(e) = session.begin(0, &tokens) { + llm_error!(app, log_buf, log_file, "[mtp] session.begin failed: {e}"); + token_tx.send(InferToken::Error(format!("MTP begin: {e}"))).ok(); + return; + } + + // Sample the first token from the prefill's last logits. + let first_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), + ]); + let first_token = first_sampler.sample(target_ctx, prefill.n_tokens() - 1); + drop(first_sampler); + + run_sampling_loop_mtp( + model, + target_ctx, + &mut draft_ctx, + &mut session, + app, + log_buf, + log_file, + ¶ms, + token_tx, + n_prompt, + first_token, + gpu_guard, + ); + + // Explicit drop order: session BEFORE draft_ctx, since session holds a + // raw pointer into draft_ctx's underlying llama_context_p. + drop(session); + drop(draft_ctx); +} + +fn mtp_prefill_needs_logits(position: usize, n_prompt: usize) -> bool { + position + 1 == n_prompt +} + +#[cfg(test)] +mod tests { + use super::mtp_prefill_needs_logits; + + #[test] + fn mtp_prefill_logits_only_on_final_prompt_token() { + let n_prompt = 8; + let mask: Vec = (0..n_prompt) + .map(|position| mtp_prefill_needs_logits(position, n_prompt)) + .collect(); + + assert_eq!(mask, vec![false, false, false, false, false, false, false, true]); + } + + #[test] + fn mtp_prefill_logits_handles_single_token_prompt() { + assert!(mtp_prefill_needs_logits(0, 1)); + } +} diff --git a/crates/skill-llm/src/engine/init.rs b/crates/skill-llm/src/engine/init.rs index b9954b0b..49d09892 100644 --- a/crates/skill-llm/src/engine/init.rs +++ b/crates/skill-llm/src/engine/init.rs @@ -121,6 +121,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..33caa0dd 100644 --- a/crates/skill-llm/src/engine/mod.rs +++ b/crates/skill-llm/src/engine/mod.rs @@ -49,7 +49,10 @@ mod actor; mod generation; pub mod images; mod init; +#[cfg(feature = "llm-rlx")] +mod rlx_backend; mod sampling; +mod sampling_mtp; mod think_tracker; pub mod tool_orchestration; 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..d3715cff --- /dev/null +++ b/crates/skill-llm/src/engine/rlx_backend.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! Experimental RLX text-generation adapter. + +use anyhow::{anyhow, Result}; +use llama_cpp_4::{ + model::{AddBos, LlamaModel, Special}, + token::LlamaToken, +}; +use tokio::sync::mpsc::UnboundedSender; + +use super::protocol::{GenParams, InferToken}; +use crate::config::LlmConfig; + +pub(super) struct RlxTextRunner { + runner: rlx::run::Qwen3Runner, +} + +impl RlxTextRunner { + pub(super) fn load(model_path: &std::path::Path, config: &LlmConfig) -> Result { + let device = parse_device(&config.rlx_device)?; + let mut builder = rlx::run::Qwen3Runner::builder() + .weights(model_path) + .device(device) + .max_seq(config.rlx_max_seq) + .precision(rlx::run::Qwen3Precision::F32) + .stream(true) + .sample(sample_opts(&GenParams::default())); + + if let Some(gb) = config.rlx_max_memory_gb { + builder = builder.max_memory_gb(gb); + } + + Ok(Self { + runner: builder.build()?, + }) + } + + pub(super) fn generate( + &mut self, + tokenizer: &LlamaModel, + prompt: &str, + params: GenParams, + token_tx: UnboundedSender, + ) { + let prompt_tokens = match tokenizer.str_to_token(prompt, AddBos::Always) { + Ok(tokens) => tokens, + Err(e) => { + token_tx + .send(InferToken::Error(format!("RLX tokenization failed: {e}"))) + .ok(); + return; + } + }; + + let prompt_ids: Vec = prompt_tokens + .iter() + .filter_map(|tok| u32::try_from(tok.0).ok()) + .collect(); + if prompt_ids.is_empty() { + token_tx + .send(InferToken::Error( + "RLX prompt tokenization returned no usable tokens".into(), + )) + .ok(); + return; + } + + let mut text = String::new(); + let mut completion_tokens = 0usize; + let max_tokens = params.max_tokens; + let stop = params.stop.clone(); + + let result = self.runner.generate(&prompt_ids, max_tokens, |tok| { + completion_tokens += 1; + let piece = tokenizer + .token_to_str(LlamaToken(tok as i32), Special::Plaintext) + .unwrap_or_default(); + if piece.is_empty() { + return; + } + text.push_str(&piece); + token_tx.send(InferToken::Delta(piece)).ok(); + }); + + 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() && 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(); + } +} + +fn sample_opts(params: &GenParams) -> rlx::models::qwen3::SampleOpts { + let opts = if params.temperature <= 0.0 { + rlx::models::qwen3::SampleOpts::greedy() + } else { + rlx::models::qwen3::SampleOpts::temperature(params.temperature, params.seed as u64) + }; + opts.with_top_k(params.top_k.max(0) as usize) + .with_top_p(params.top_p.clamp(0.0, 1.0)) +} + +fn parse_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}'")), + } +} diff --git a/crates/skill-llm/src/engine/sampling_mtp.rs b/crates/skill-llm/src/engine/sampling_mtp.rs new file mode 100644 index 00000000..d73abf01 --- /dev/null +++ b/crates/skill-llm/src/engine/sampling_mtp.rs @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +//! MTP (Multi-Token Prediction) speculative-decoding loop. +//! +//! Mirrors the structure of the upstream `examples/mtp/src/main.rs` in +//! llama-cpp-rs v0.2.56, adapted to skill-llm's streaming token_tx + stop +//! string conventions. Per round the loop: +//! +//! 1. `session.draft(...)` → up to `n_draft_max` speculative tokens. +//! 2. Build verify batch `[last_token, drafts...]` with `logits = true`. +//! 3. Roll back the draft context's KV before the AR pre-advance. +//! 4. `target_ctx.decode(verify)` + `session.process(verify)`. +//! 5. Sample target at each output index; find longest matching prefix. +//! 6. Roll back rejected suffix on BOTH contexts. +//! 7. `session.accept(n_accepted)` and emit accepted drafts + new token. +//! +//! Limitations vs the standard `sampling::run_sampling_loop`: +//! - No `` budget injection (interaction with multi-token +//! rounds is non-trivial). Generation honours `thinking_budget == Some(0)` +//! via the prefill prefix only. +//! - No partial stop-string holdback: stop matches are detected after each +//! round on the accumulated text. Drafted tokens past a stop boundary +//! are still emitted within the round — acceptable for the current +//! stop strings (all short fixed sentinels like `<|im_end|>`). + +use tokio::sync::mpsc::UnboundedSender; + +use llama_cpp_4::{ + llama_batch::LlamaBatch, model::Special, mtp::MtpSession, sampling::LlamaSampler, token::LlamaToken, +}; + +use super::generation::GpuMemoryGuard; +use super::logging::{LlmLogBuffer, LlmLogFile}; +use super::protocol::{GenParams, InferToken}; +use crate::event::LlmEventEmitter; + +/// Run the MTP speculative-decoding loop. +/// +/// Preconditions: +/// * `target_ctx` already contains the fully-decoded prompt, and the prefill +/// batch has been processed by the MTP session so it can consume pre-norm +/// embeddings. +/// * `session.process(prefill_batch)` and `session.begin(0, &tokens)` have +/// already been called by the caller. +/// * `first_token` was sampled from the prefill's last logits. +#[allow(clippy::too_many_arguments, clippy::cast_possible_truncation, clippy::cast_sign_loss)] +pub(super) fn run_sampling_loop_mtp( + model: &llama_cpp_4::model::LlamaModel, + target_ctx: &mut llama_cpp_4::context::LlamaContext<'_>, + draft_ctx: &mut llama_cpp_4::context::LlamaContext<'_>, + session: &mut MtpSession, + app: &dyn LlmEventEmitter, + log_buf: &LlmLogBuffer, + log_file: Option<&LlmLogFile>, + params: &GenParams, + token_tx: UnboundedSender, + n_prompt: usize, + first_token: LlamaToken, + gpu_guard: GpuMemoryGuard, +) { + let n_ctx = target_ctx.n_ctx() as usize; + let n_draft_max = session.n_draft_max(); + + 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), + ]); + sampler.accept(first_token); + + 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()); + } + } + + // Emit the first token (sampled from prefill before the loop starts). + let mut accumulated = String::new(); + let emit = |s: &str, acc: &mut String, tx: &UnboundedSender| -> bool { + if s.is_empty() { + return true; + } + acc.push_str(s); + tx.send(InferToken::Delta(s.to_string())).is_ok() + }; + let first_piece = model.token_to_str(first_token, Special::Plaintext).unwrap_or_default(); + if !emit(&first_piece, &mut accumulated, &token_tx) { + return; + } + + let mut last_token = first_token; + let mut n_past = n_prompt as i32; + let mut n_generated: usize = 1; + let max_new = params.max_tokens.min(n_ctx.saturating_sub(n_prompt)); + + let mut n_rounds: u64 = 0; + let mut n_drafts_total: u64 = 0; + let mut n_accepted_total: u64 = 0; + let mut finish_reason = "length".to_string(); + + let verify_cap = (n_draft_max as usize + 1).max(target_ctx.n_batch() as usize); + let mut verify = LlamaBatch::new(verify_cap, 1); + + 'gen: loop { + if n_generated >= max_new { + break; + } + if model.is_eog_token(last_token) { + finish_reason = "stop".to_string(); + break; + } + + // Periodic GPU memory check (every 8 rounds — each round produces + // up to n_draft_max+1 tokens, so ~64 tokens at n_draft_max=7). + if n_rounds.is_multiple_of(8) && 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 MTP 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).]*", + free_gb.unwrap_or(0.0) + ))) + .ok(); + finish_reason = "gpu_memory".to_string(); + break; + } + } + + // 1. Get drafts. + let drafts = match session.draft(0, n_past, last_token) { + Ok(d) => d, + Err(e) => { + llm_error!(app, log_buf, log_file, "MTP draft failed: {e}"); + token_tx.send(InferToken::Error(format!("MTP draft error: {e}"))).ok(); + return; + } + }; + n_rounds += 1; + n_drafts_total += drafts.len() as u64; + + // 2. Build verify batch: [last_token, drafts...]. + verify.clear(); + if verify.add(last_token, n_past, &[0], true).is_err() { + token_tx.send(InferToken::Error("verify batch overflow".into())).ok(); + return; + } + for (i, d) in drafts.iter().enumerate() { + if verify.add(*d, n_past + 1 + i as i32, &[0], true).is_err() { + token_tx.send(InferToken::Error("verify batch overflow".into())).ok(); + return; + } + } + let n_verify = verify.n_tokens(); + + // 3. Roll back draft KV before re-decoding via session.process(verify). + if let Err(e) = draft_ctx.clear_kv_cache_seq(Some(0), Some(n_past as u32), None) { + llm_error!(app, log_buf, log_file, "draft KV rollback failed: {e}"); + token_tx.send(InferToken::Error(format!("draft KV rollback: {e}"))).ok(); + return; + } + + // 4. Verify decode + session.process(verify). + if let Err(e) = target_ctx.decode(&mut verify) { + llm_error!(app, log_buf, log_file, "MTP verify decode failed: {e}"); + token_tx.send(InferToken::Error(format!("verify decode: {e}"))).ok(); + return; + } + if let Err(e) = session.process(&verify) { + llm_error!(app, log_buf, log_file, "MTP process(verify) failed: {e}"); + token_tx.send(InferToken::Error(format!("MTP process: {e}"))).ok(); + return; + } + + // 5. Sample target at output position 0 (predicts draft[0]) and walk + // forward as long as drafts keep matching. + let mut n_accepted: usize = 0; + let mut next_token = sampler.sample(target_ctx, 0); + sampler.accept(next_token); + + for (i, draft) in drafts.iter().enumerate() { + if next_token == *draft { + n_accepted = i + 1; + if i + 1 < n_verify as usize { + next_token = sampler.sample(target_ctx, (i + 1) as i32); + sampler.accept(next_token); + } + } else { + break; + } + } + n_accepted_total += n_accepted as u64; + + let new_n_past = n_past + 1 + n_accepted as i32; + + // 6. Roll back the rejected suffix on BOTH contexts. + if (n_accepted as i32) < drafts.len() as i32 { + match target_ctx.clear_kv_cache_seq(Some(0), Some(new_n_past as u32), None) { + Ok(true) => {} + Ok(false) => { + llm_error!( + app, + log_buf, + log_file, + "target ctx refused partial seq_rm at pos {new_n_past} — \ + with_n_rs_seq(>0) must be set on the target context" + ); + token_tx + .send(InferToken::Error("target KV rollback rejected".into())) + .ok(); + return; + } + Err(e) => { + llm_error!(app, log_buf, log_file, "target KV rollback errored: {e}"); + token_tx + .send(InferToken::Error(format!("target KV rollback: {e}"))) + .ok(); + return; + } + } + match draft_ctx.clear_kv_cache_seq(Some(0), Some(new_n_past as u32), None) { + Ok(true) => {} + Ok(false) => { + llm_error!( + app, + log_buf, + log_file, + "draft ctx refused partial seq_rm at pos {new_n_past}" + ); + token_tx + .send(InferToken::Error("draft KV rollback rejected".into())) + .ok(); + return; + } + Err(e) => { + llm_error!(app, log_buf, log_file, "draft KV rollback errored: {e}"); + token_tx.send(InferToken::Error(format!("draft KV rollback: {e}"))).ok(); + return; + } + } + } + + // 7. Tell session how many drafts were accepted. + if let Err(e) = session.accept(0, n_accepted as u16) { + llm_error!(app, log_buf, log_file, "MTP accept failed: {e}"); + token_tx.send(InferToken::Error(format!("MTP accept: {e}"))).ok(); + return; + } + + // Emit accepted drafts, then the newly sampled token. Check EOG and + // stop strings after each emission so we never decode past a stop. + for d in drafts.iter().take(n_accepted) { + if model.is_eog_token(*d) { + finish_reason = "stop".to_string(); + break 'gen; + } + let piece = model.token_to_str(*d, Special::Plaintext).unwrap_or_default(); + if !emit(&piece, &mut accumulated, &token_tx) { + return; + } + n_generated += 1; + for s in &stop_strings { + if accumulated.ends_with(s.as_str()) { + finish_reason = "stop".to_string(); + break 'gen; + } + } + } + + if model.is_eog_token(next_token) { + finish_reason = "stop".to_string(); + break; + } + let next_piece = model.token_to_str(next_token, Special::Plaintext).unwrap_or_default(); + if !emit(&next_piece, &mut accumulated, &token_tx) { + return; + } + n_generated += 1; + for s in &stop_strings { + if accumulated.ends_with(s.as_str()) { + finish_reason = "stop".to_string(); + break 'gen; + } + } + + last_token = next_token; + n_past = new_n_past; + } + + let acceptance = if n_drafts_total == 0 { + 0.0 + } else { + n_accepted_total as f64 / n_drafts_total as f64 + }; + llm_info!( + app, + log_buf, + log_file, + "[mtp] generation done — prompt={n_prompt} completion={n_generated} ctx={n_ctx} \ + finish={finish_reason} rounds={n_rounds} drafts={n_drafts_total} \ + accepted={n_accepted_total} ({:.1}%)", + 100.0 * acceptance + ); + token_tx + .send(InferToken::Done { + finish_reason, + prompt_tokens: n_prompt, + completion_tokens: n_generated, + n_ctx, + }) + .ok(); +} diff --git a/crates/skill-llm/src/lib.rs b/crates/skill-llm/src/lib.rs index 4c2af4ea..98c663b6 100644 --- a/crates/skill-llm/src/lib.rs +++ b/crates/skill-llm/src/lib.rs @@ -46,7 +46,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..cc0e8908 --- /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 llama-cpp-4 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-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-router/Cargo.toml b/crates/skill-router/Cargo.toml index 935fd1af..12f1cd87 100644 --- a/crates/skill-router/Cargo.toml +++ b/crates/skill-router/Cargo.toml @@ -10,6 +10,12 @@ default = ["gpu"] gpu = ["dep:burn-cubecl", "dep:cubecl", "fast-umap/gpu"] mlx = ["dep:burn-mlx", "fast-umap/mlx"] cpu = ["burn/ndarray"] +# 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 } @@ -27,6 +33,7 @@ burn-mlx = { git = "https://github.com/eidola-ai/burn-mlx", branch = "bur cubecl = { version = "0.9.0", features = ["wgpu"], optional = true } crossbeam-channel = "0.5.15" half = { version = "2.4", features = ["num-traits"] } +hotpath = { version = "0.16", optional = true } [lints] workspace = 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..4ff1c5ed --- /dev/null +++ b/crates/skill-router/examples/umap_hotpath.rs @@ -0,0 +1,128 @@ +// 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 +// --features='mlx,hotpath' # MLX backend (Apple) +// +// Adjust N_A / N_B below for larger/smaller workloads. + +#[cfg(all(feature = "hotpath", any(feature = "gpu", feature = "mlx")))] +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", any(feature = "gpu", feature = "mlx")))] +fn main() { + runner::run(); +} + +#[cfg(not(all(feature = "hotpath", any(feature = "gpu", feature = "mlx"))))] +fn main() { + eprintln!( + "umap_hotpath requires --features='hotpath' plus one of 'gpu'/'mlx'.\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..05b4e7c9 100644 --- a/crates/skill-router/src/lib.rs +++ b/crates/skill-router/src/lib.rs @@ -80,6 +80,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 +110,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 +136,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 +171,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 +210,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 @@ -435,6 +453,7 @@ macro_rules! fit_umap { type FitResult = Result>, Box>; #[cfg(feature = "gpu")] +#[cfg_attr(feature = "hotpath", hotpath::measure)] fn fit_umap_gpu( config: fast_umap::UmapConfig, data: Vec>, @@ -450,6 +469,7 @@ fn fit_umap_gpu( } #[cfg(feature = "mlx")] +#[cfg_attr(feature = "hotpath", hotpath::measure)] fn fit_umap_mlx( config: fast_umap::UmapConfig, data: Vec>, @@ -471,6 +491,7 @@ fn fit_umap_mlx( /// /// 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, diff --git a/crates/skill-router/tests/umap_e2e_bench.rs b/crates/skill-router/tests/umap_e2e_bench.rs index 10c569b1..2c289184 100644 --- a/crates/skill-router/tests/umap_e2e_bench.rs +++ b/crates/skill-router/tests/umap_e2e_bench.rs @@ -161,6 +161,7 @@ fn umap_e2e_small() { /// Medium dataset (1000 points) — representative of a typical EEG session pair. #[test] +#[ignore = "slow benchmark; run with --include-ignored or via npm run test:mlx-e2e"] #[cfg(any(feature = "gpu", feature = "mlx"))] fn umap_e2e_medium() { let (skill_dir, a_start, a_end, b_start, b_end) = seed_synthetic_embeddings("medium", 500, 500); @@ -195,6 +196,7 @@ fn umap_e2e_medium() { /// Large dataset (5000 points) — stress test matching real-world cache sizes. #[test] +#[ignore = "slow benchmark; run with --include-ignored or via npm run test:mlx-e2e"] #[cfg(any(feature = "gpu", feature = "mlx"))] fn umap_e2e_large() { let (skill_dir, a_start, a_end, b_start, b_end) = seed_synthetic_embeddings("large", 2500, 2500); 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..a9c5108d 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. @@ -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, @@ -1060,6 +1089,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 +1114,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 +1135,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 +1275,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 +1303,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 +1329,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 +1339,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 +1367,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-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-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..8291a507 100644 --- a/deny.toml +++ b/deny.toml @@ -90,6 +90,7 @@ exceptions = [ { allow = ["GPL-3.0-only"], crate = "skill-tools" }, { allow = ["GPL-3.0-only"], crate = "skill-tray" }, { 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), @@ -203,14 +204,12 @@ skip = [ { 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" }, @@ -252,4 +251,5 @@ allow-git = [ "https://github.com/eugenehp/cubek.git", "https://github.com/eugenehp/btleplug.git", "https://github.com/eugenehp/gtk-rs-core.git", + "https://github.com/eugenehp/tribev2-rs", ] 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/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 b7f75865..4bfa9703 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "neuroskill", - "version": "0.0.129", + "version": "0.0.130-rc.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neuroskill", - "version": "0.0.129", + "version": "0.0.130-rc.25", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index c68afc55..82f30c23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neuroskill", - "version": "0.0.129", + "version": "0.0.130-rc.31", "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", 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/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..46b0eaa6 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" @@ -531,7 +546,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/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/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..2ab47acf 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,7 +190,7 @@ 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 "UMAP MLX E2E" cargo test -p skill-router --features mlx -- umap_e2e --nocapture --test-threads=1 --include-ignored || { $STOP_ON_FAIL && break; } run_suite "FFT MLX E2E" cargo test -p skill-eeg --features mlx -- fft_e2e --nocapture || { $STOP_ON_FAIL && break; } else skip_suite "MLX E2E" "requires macOS with Apple Silicon" 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..d28f2166 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.130-rc.31" 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 ────────────────────────────────────────────────── # @@ -126,8 +126,8 @@ neutts = { version = "0.1.1", features = ["backbone", "espeak", "fast", "wgpu"], # 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"] } 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..19ce2f12 100644 --- a/src-tauri/llm_catalog.json +++ b/src-tauri/llm_catalog.json @@ -12,7 +12,7 @@ "small" ], "is_mmproj": false, - "params_b": 4.0, + "params_b": 4, "max_context_length": 131072 }, "qwen35-9b": { @@ -25,7 +25,7 @@ "small" ], "is_mmproj": false, - "params_b": 9.0, + "params_b": 9, "max_context_length": 131072 }, "qwen251-coder-7b-instruct": { @@ -51,7 +51,7 @@ "large" ], "is_mmproj": false, - "params_b": 27.0, + "params_b": 27, "max_context_length": 131072 }, "qwen35-27b-claude-opus-distilled": { @@ -64,7 +64,7 @@ "large" ], "is_mmproj": false, - "params_b": 27.0, + "params_b": 27, "max_context_length": 131072 }, "qwen36-27b": { @@ -78,7 +78,7 @@ "large" ], "is_mmproj": false, - "params_b": 27.0, + "params_b": 27, "max_context_length": 131072 }, "qwen36-35b-a3b": { @@ -92,7 +92,7 @@ "large" ], "is_mmproj": false, - "params_b": 35.0, + "params_b": 35, "max_context_length": 131072 }, "glm-51": { @@ -105,7 +105,7 @@ "large" ], "is_mmproj": false, - "params_b": 9.0, + "params_b": 9, "max_context_length": 131072 }, "minimax-m27": { @@ -118,7 +118,7 @@ "large" ], "is_mmproj": false, - "params_b": 27.0, + "params_b": 27, "max_context_length": 131072 }, "gpt-oss-20b": { @@ -131,7 +131,7 @@ "large" ], "is_mmproj": false, - "params_b": 20.0, + "params_b": 20, "max_context_length": 131072 }, "qwen3-coder-next": { @@ -144,7 +144,7 @@ "large" ], "is_mmproj": false, - "params_b": 14.0, + "params_b": 14, "max_context_length": 65536 }, "qwen3-vl-30b": { @@ -170,7 +170,7 @@ "medium" ], "is_mmproj": false, - "params_b": 14.0, + "params_b": 14, "max_context_length": 128000 }, "ministral-14b-reasoning": { @@ -182,7 +182,7 @@ "medium" ], "is_mmproj": false, - "params_b": 14.0, + "params_b": 14, "max_context_length": 128000 }, "gemma3-270m": { @@ -207,7 +207,7 @@ "large" ], "is_mmproj": false, - "params_b": 31.0, + "params_b": 31, "max_context_length": 131072 }, "gemma4-26b-a4b": { @@ -221,7 +221,7 @@ "large" ], "is_mmproj": false, - "params_b": 26.0, + "params_b": 26, "max_context_length": 131072 }, "gemma4-e4b": { @@ -234,7 +234,7 @@ "small" ], "is_mmproj": false, - "params_b": 4.0, + "params_b": 4, "max_context_length": 131072 }, "gemma4-e2b": { @@ -247,7 +247,7 @@ "tiny" ], "is_mmproj": false, - "params_b": 2.0, + "params_b": 2, "max_context_length": 131072 }, "phi4-reasoning-plus": { @@ -272,7 +272,7 @@ "medium" ], "is_mmproj": false, - "params_b": 9.0, + "params_b": 9, "max_context_length": 32768 }, "qwen35-4b-hauhau-aggressive": { @@ -285,7 +285,7 @@ "small" ], "is_mmproj": false, - "params_b": 4.0, + "params_b": 4, "max_context_length": 131072 }, "qwen35-9b-hauhau-aggressive": { @@ -298,7 +298,7 @@ "small" ], "is_mmproj": false, - "params_b": 9.0, + "params_b": 9, "max_context_length": 131072 }, "qwen35-27b-hauhau-aggressive": { @@ -311,7 +311,7 @@ "large" ], "is_mmproj": false, - "params_b": 27.0, + "params_b": 27, "max_context_length": 131072 }, "qwen35-35b-a3b-hauhau-aggressive": { @@ -324,7 +324,7 @@ "large" ], "is_mmproj": false, - "params_b": 35.0, + "params_b": 35, "max_context_length": 131072 }, "qwen3-30b-a3b-instruct": { @@ -338,7 +338,7 @@ "large" ], "is_mmproj": false, - "params_b": 30.0, + "params_b": 30, "max_context_length": 131072 }, "lfm25-vl-1.6b": { @@ -391,7 +391,7 @@ "large" ], "is_mmproj": false, - "params_b": 456.0, + "params_b": 456, "max_context_length": 131072 }, "bonsai-8b": { @@ -443,8 +443,51 @@ "small" ], "is_mmproj": false, - "params_b": 4.0, + "params_b": 4, "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, + "params_b": 128, + "max_context_length": 131072 + }, + "nemotron-3-nano-omni-30b": { + "name": "NVIDIA Nemotron-3 Nano Omni 30B", + "description": "NVIDIA's multimodal MoE model (30B, 3B active) with video, audio, image, and text understanding. Supports reasoning, tool calling, OCR, and GUI automation.", + "repo": "unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF", + "tags": [ + "chat", + "reasoning", + "multimodal", + "moe" + ], + "is_mmproj": true, + "params_b": 30, + "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": [ @@ -4073,6 +4116,464 @@ "size_gb": 35.81, "description": "8-bit with important layers at higher precision", "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": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ2_M.gguf", + "quant": "IQ2_M", + "size_gb": 18.5, + "description": "Ultra-low quality imatrix; smallest useful option", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q2_K_XL.gguf", + "quant": "Q2_K_XL", + "size_gb": 18.5, + "description": "Ultra-low quality; Q8_0 embed/output weights", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ3_S.gguf", + "quant": "IQ3_S", + "size_gb": 18.82, + "description": "Low quality imatrix", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ3_XXS.gguf", + "quant": "IQ3_XXS", + "size_gb": 19.46, + "description": "Low quality imatrix", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ4_XS.gguf", + "quant": "IQ4_XS", + "size_gb": 19.54, + "description": "Decent quality; smaller than Q4_K_S", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-IQ4_NL.gguf", + "quant": "IQ4_NL", + "size_gb": 19.54, + "description": "Decent quality; good for ARM CPU inference", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-MXFP4_MOE.gguf", + "quant": "MXFP4_MOE", + "size_gb": 21.73, + "description": "MX FP4 MoE quant -- fast on supported hardware" + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q4_K_S.gguf", + "quant": "Q4_K_S", + "size_gb": 23.05, + "description": "Good quality with space savings" + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 23.89, + "description": "Recommended -- best quality/size tradeoff", + "recommended": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q5_K_S.gguf", + "quant": "Q5_K_S", + "size_gb": 24.8, + "description": "High quality", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_gb": 29, + "description": "High quality; >= 32 GB VRAM", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-UD-Q6_K.gguf", + "quant": "Q6_K", + "size_gb": 33.59, + "description": "Very high quality, near perfect", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 33.59, + "description": "Effectively lossless; very large", + "advanced": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "mmproj-BF16.gguf", + "quant": "BF16", + "size_gb": 1.59, + "description": "Nemotron-3 Nano Omni vision/audio projector -- BF16 (recommended)", + "recommended": true + }, + { + "family": "nemotron-3-nano-omni-30b", + "filename": "mmproj-F16.gguf", + "quant": "F16", + "size_gb": 1.59, + "description": "Nemotron-3 Nano Omni vision/audio projector -- FP16" + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-IQ2_XXS-mtp.gguf", + "remote_filename": "Qwen3.6-27B-UD-IQ2_XXS.gguf", + "quant": "IQ2_XXS", + "size_gb": 8.75, + "description": "Ultra-small 2-bit with MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-IQ2_M-mtp.gguf", + "remote_filename": "Qwen3.6-27B-UD-IQ2_M.gguf", + "quant": "IQ2_M", + "size_gb": 9.74, + "description": "2-bit with MTP; fits 12 GB VRAM", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-IQ3_XXS-mtp.gguf", + "remote_filename": "Qwen3.6-27B-UD-IQ3_XXS.gguf", + "quant": "IQ3_XXS", + "size_gb": 11.18, + "description": "Compact 3-bit with MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q3_K_S-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q3_K_S.gguf", + "quant": "Q3_K_S", + "size_gb": 11.51, + "description": "Small 3-bit with MTP; fits 16 GB VRAM", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q3_K_M-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q3_K_M.gguf", + "quant": "Q3_K_M", + "size_gb": 12.65, + "description": "Medium 3-bit with MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q2_K_XL-mtp.gguf", + "remote_filename": "Qwen3.6-27B-UD-Q2_K_XL.gguf", + "quant": "Q2_K_XL", + "size_gb": 13.47, + "description": "2-bit with important layers at higher precision and MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q3_K_XL-mtp.gguf", + "remote_filename": "Qwen3.6-27B-UD-Q3_K_XL.gguf", + "quant": "Q3_K_XL", + "size_gb": 13.48, + "description": "3-bit with important layers at higher precision and MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-IQ4_XS-mtp.gguf", + "remote_filename": "Qwen3.6-27B-IQ4_XS.gguf", + "quant": "IQ4_XS", + "size_gb": 14.47, + "description": "Compact 4-bit with MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q4_0-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q4_0.gguf", + "quant": "Q4_0", + "size_gb": 14.71, + "description": "4-bit with MTP; fits 16 GB VRAM", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q4_K_S-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q4_K_S.gguf", + "quant": "Q4_K_S", + "size_gb": 14.76, + "description": "4-bit k-quant small with MTP" + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-IQ4_NL-mtp.gguf", + "remote_filename": "Qwen3.6-27B-IQ4_NL.gguf", + "quant": "IQ4_NL", + "size_gb": 14.97, + "description": "4-bit non-linear with MTP; good quality/size balance", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q4_K_M-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 15.83, + "description": "Best quality/size balance with MTP; needs 16 GB VRAM or 24 GB RAM", + "recommended": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q4_1-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q4_1.gguf", + "quant": "Q4_1", + "size_gb": 16.07, + "description": "4-bit variant with MTP; slightly higher quality than Q4_0", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q4_K_XL-mtp.gguf", + "remote_filename": "Qwen3.6-27B-UD-Q4_K_XL.gguf", + "quant": "Q4_K_XL", + "size_gb": 16.4, + "description": "4-bit with important layers at higher precision and MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q5_K_S-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q5_K_S.gguf", + "quant": "Q5_K_S", + "size_gb": 17.66, + "description": "5-bit with MTP; needs 24 GB VRAM", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q5_K_M-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q5_K_M.gguf", + "quant": "Q5_K_M", + "size_gb": 18.33, + "description": "Higher quality 5-bit with MTP; needs 24 GB VRAM", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q5_K_XL-mtp.gguf", + "remote_filename": "Qwen3.6-27B-UD-Q5_K_XL.gguf", + "quant": "Q5_K_XL", + "size_gb": 18.66, + "description": "5-bit with important layers at higher precision and MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q6_K-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q6_K.gguf", + "quant": "Q6_K", + "size_gb": 20.99, + "description": "6-bit with MTP; high quality, needs 32 GB RAM", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q6_K_XL-mtp.gguf", + "remote_filename": "Qwen3.6-27B-UD-Q6_K_XL.gguf", + "quant": "Q6_K_XL", + "size_gb": 23.88, + "description": "6-bit with important layers at higher precision and MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-Q8_0-mtp.gguf", + "remote_filename": "Qwen3.6-27B-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 27.05, + "description": "Near-lossless 8-bit with MTP; needs 48 GB RAM", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "Qwen3.6-27B-UD-Q8_K_XL-mtp.gguf", + "remote_filename": "Qwen3.6-27B-UD-Q8_K_XL.gguf", + "quant": "Q8_K_XL", + "size_gb": 32.89, + "description": "8-bit with important layers at higher precision and MTP", + "advanced": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "mmproj-Qwen3.6-27B-BF16-mtp.gguf", + "remote_filename": "mmproj-BF16.gguf", + "quant": "BF16", + "size_gb": 0.87, + "description": "Qwen3.6-27B MTP vision projector -- BF16 (recommended)", + "recommended": true + }, + { + "family": "qwen36-27b-mtp", + "filename": "mmproj-Qwen3.6-27B-F16-mtp.gguf", + "remote_filename": "mmproj-F16.gguf", + "quant": "F16", + "size_gb": 0.86, + "description": "Qwen3.6-27B MTP vision projector -- FP16" + }, + { + "family": "qwen36-27b-mtp", + "filename": "mmproj-Qwen3.6-27B-F32-mtp.gguf", + "remote_filename": "mmproj-F32.gguf", + "quant": "F32", + "size_gb": 1.72, + "description": "Qwen3.6-27B MTP vision projector -- FP32", + "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/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/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..bbe93c41 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.130-rc.31", "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/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/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"} { {/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 +1055,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..206bc9ba 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"; @@ -76,6 +77,7 @@ interface LlmConfig { cache_type_k: string; cache_type_v: string; attn_rot_disabled: boolean; + mtp_draft_count: number; } interface ModelHardwareFit { @@ -132,6 +134,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 +408,18 @@ onDestroy(() => { onSelectMmproj={selectMmproj} /> + + + +{#if activeEntry?.mtp} + { config = { ...config, mtp_draft_count: val }; await saveConfig(); }} +/> +{/if} + 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"); + +
- -