diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 000000000..0e6fec01d --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,50 @@ +# cargo-audit ignore list — all transitive deps we cannot directly upgrade. +# These are informational-only advisories (unmaintained / unsound); +# no actual vulnerabilities are present. + +# Yanked crates — transitive deps we can't control. +[yanked] +enabled = false + +[advisories] +ignore = [ + # ── gtk-rs GTK3 family — unmaintained, no GTK4 upgrade path until Tauri migrates ── + "RUSTSEC-2024-0411", # gdkwayland-sys + "RUSTSEC-2024-0412", # gdk + "RUSTSEC-2024-0413", # atk + "RUSTSEC-2024-0414", # gdkx11-sys + "RUSTSEC-2024-0415", # gtk + "RUSTSEC-2024-0416", # atk-sys + "RUSTSEC-2024-0417", # gdkx11 + "RUSTSEC-2024-0418", # gdk-sys + "RUSTSEC-2024-0419", # gtk3-macros + "RUSTSEC-2024-0420", # gtk-sys + + # ── glib — unsound VariantStrIter, patched in >=0.20.0; blocked by gtk3 above ── + "RUSTSEC-2024-0429", # glib + + # ── Other unmaintained transitive deps ──────────────────────────────────── + "RUSTSEC-2021-0139", # ansi_term (via structopt/clap2 in iroh test clients) + "RUSTSEC-2023-0089", # atomic-polyfill (via heapless 0.7 in iroh stack) + "RUSTSEC-2024-0370", # proc-macro-error (via gtk3-macros) + "RUSTSEC-2024-0375", # atty unmaintained (via structopt/clap2 in iroh test clients) + "RUSTSEC-2024-0436", # paste + "RUSTSEC-2025-0057", # fxhash + "RUSTSEC-2025-0075", # unic-char-range + "RUSTSEC-2025-0080", # unic-common + "RUSTSEC-2025-0081", # unic-char-property + "RUSTSEC-2025-0098", # unic-ucd-version + "RUSTSEC-2025-0100", # unic-ucd-ident + "RUSTSEC-2025-0119", # number_prefix + "RUSTSEC-2025-0134", # rustls-pemfile (via oura-api/reqwest 0.11) + "RUSTSEC-2025-0141", # bincode + + # ── Unsound warnings in legacy transitive deps ──────────────────────────── + "RUSTSEC-2021-0145", # atty potential unaligned read (legacy clap2 chain) + + # ── lru — unsound IterMut, patched in >=0.16.3; transitive dep ─────────── + "RUSTSEC-2026-0002", # lru + + # ── rand — unsound with custom logger using rand::rng(); transitive dep ── + "RUSTSEC-2026-0097", # rand (0.7.3 via tauri/phf, 0.8.5 via keyring/emotiv/etc.) +] diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..52d77b3a1 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,204 @@ +# ── Workspace-level Cargo configuration ──────────────────────────────────────── +# +# 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). + +# ── Default build target ────────────────────────────────────────────────────── +# +# cargo's [build] section has no per-host-OS conditionals, so a hardcoded +# `target = "aarch64-apple-darwin"` here would break Windows / Linux builds +# ("can't find crate for `core`"). +# +# Platform default is therefore left as the host triple. Per-platform +# targeting is handled at two levels: +# +# 1. npm / Tauri builds (recommended): +# npm run tauri:build ← auto-selects target via scripts/tauri-build.js +# macOS → --target aarch64-apple-darwin +# Windows/Linux → host triple (no --target flag) +# +# 2. Raw `cargo` on macOS: +# Either pass --target aarch64-apple-darwin explicitly, or let direnv +# source the repo-root .envrc which exports CARGO_BUILD_TARGET for you. +# +# ── Windows build prerequisite: LLVM / libclang ─────────────────────────────── +# +# llama-cpp-sys uses bindgen to generate Rust↔C bindings at build time. +# bindgen requires libclang.dll, which ships with the LLVM toolchain. +# +# One-time setup (run once, then restart your terminal): +# +# winget install LLVM.LLVM +# +# # Then add to your user environment (PowerShell): +# [System.Environment]::SetEnvironmentVariable( +# "LIBCLANG_PATH", "C:\Program Files\LLVM\bin", "User") +# +# Without this, cargo build fails with: +# "Unable to find libclang: couldn't find any valid shared libraries +# matching: ['clang.dll', 'libclang.dll']" +# +# LIBCLANG_PATH is intentionally NOT set here because the path is +# installation-dependent and force=true would break machines where LLVM +# lives elsewhere. Set it in your system/user environment variables instead. + +[build] +# target intentionally omitted — defaults to the host triple +# +# ── Shared target directory ─────────────────────────────────────────────────── +# +# All workspace members compile into src-tauri/target/. This keeps the +# binary output where the Tauri CLI and build scripts (tauri-build.js, +# assemble-macos-app.sh, etc.) expect it, and avoids the multi-GB +# duplication that occurred when individual crates had their own target/. +target-dir = "src-tauri/target" +# +# ── Build caching (sccache) ─────────────────────────────────────────────────── +# +# Do NOT hardcode `rustc-wrapper = "sccache"` here. +# +# Some developer machines (especially fresh Windows setups) do not have +# sccache installed, and a workspace-level rustc-wrapper makes *every* cargo +# command fail with: +# "could not execute process `sccache ... rustc.exe -vV` (program not found)" +# +# Instead, scripts/tauri-build.js auto-detects sccache and sets +# RUSTC_WRAPPER only when the executable is present. + +[env] +# Dynamic CRT for cmake-based -sys crates (llama-cpp-sys, etc.). +# Several native dependencies (DirectML, ONNX Runtime, Vulkan loader) ship +# as pre-built DLLs linked against the dynamic CRT (/MD). Mixing /MT and +# /MD in the same process causes heap corruption and "side-by-side +# configuration is incorrect" errors. Using /MD everywhere is consistent +# and the NSIS installer bundles the VC++ Redistributable DLLs app-locally +# so end users don't need a separate install. +# 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: +# - llama.cpp's std::filesystem (available since 10.15) +# - MLX Metal kernels using simdgroup_matrix (requires Metal 3.0 / macOS 14+) +# +# Apple Silicon (M-series, MacBook Neo A-series) — same Rust triple for all arm64 Macs. +# Aliases: mac-neo, aarch64-apple-darwin-neo, apple-a18 (see scripts/lib/target-triples.mjs). +# +# Apple Silicon (M1+) ships with macOS 11.0 minimum, so 14.0 is fine. +# +# Two separate knobs are needed: +# +# MACOSX_DEPLOYMENT_TARGET +# Read by the `cc` crate to generate -mmacosx-version-min in +# CMAKE_C_FLAGS / CMAKE_CXX_FLAGS (passed by cmake-0.1.57). +# +# CMAKE_OSX_DEPLOYMENT_TARGET +# llama-cpp-sys-2/build.rs forwards all CMAKE_* env vars directly to +# cmake configure via config.define(key, value). cmake then appends +# its own -mmacosx-version-min AFTER CMAKE_C_FLAGS, so Clang's +# "last flag wins" rule overrides any default baked into CMAKE_C_FLAGS. +# +# force=true is required so that a system-level MACOSX_DEPLOYMENT_TARGET +# (e.g. set by Xcode to 10.13) cannot silently override these values. +# +# ⚠️ After changing either value you MUST delete the stale cmake cache: +# rm -rf src-tauri/target/release/build/llama-cpp-sys-2-*/ +# rm -rf src-tauri/target/debug/build/mlx-sys-burn-*/ +MACOSX_DEPLOYMENT_TARGET = { value = "14.0", force = true } +CMAKE_OSX_DEPLOYMENT_TARGET = { value = "14.0", force = true } + +# ── macOS: increase main-thread stack size ──────────────────────────────────── +# +# macOS defaults to an 8 MB main-thread stack. The Tauri `run()` function +# has an enormous frame because `generate_handler!` expands ~150 command +# handlers inline, plus the setup/run closures, `AppState::default()`, and +# deep generics from serde / HNSW / llama.cpp. 8 MB is not enough. +# +# The stack-size flag is emitted from build.rs via `cargo:rustc-link-arg-bins` +# so it only applies to the final executable, NOT to the lib crate (ld64 +# rejects `-stack_size` on dylibs). See build.rs for the actual value. +# +# NOTE: [target.aarch64-apple-darwin] and [target.x86_64-apple-darwin] +# sections are intentionally absent here because macOS needs no target-wide +# rustflags. The stack size is handled exclusively by build.rs. + +# ── Linux: fast linker (mold) ───────────────────────────────────────────────── +# +# mold is a drop-in replacement for ld/lld that is significantly faster for +# large binaries. When mold + clang are installed, scripts/tauri-build.js +# automatically sets CARGO_TARGET_*_LINKER and CARGO_TARGET_*_RUSTFLAGS +# environment variables so cargo uses mold without hardcoding it here. +# +# Do NOT hardcode [target.*-unknown-linux-gnu] linker/rustflags here — it +# breaks builds for developers who don't have mold or clang installed. +# +# Install (Debian/Ubuntu): sudo apt install mold clang +# Install (Fedora): sudo dnf install mold clang +# Install (Arch): sudo pacman -S mold clang +# Install (macOS): not needed — Apple's ld is already fast + +# ── Windows MSVC: increase main-thread stack size ──────────────────────────── +# +# Windows allocates only 1 MB of stack to the main thread by default (vs 8 MB +# on macOS / Linux). The Tauri + llama.cpp initialisation path uses enough +# stack (serde, HNSW, deep generics) to hit the guard page and produce a +# STATUS_STACK_OVERFLOW (0xC00000FD) crash before any window is shown. +# +# /STACK:[,] — PE header stack reservation. +# reserve = 8 MB (8 * 1024 * 1024 = 8388608) +# commit = 64 KB (default; pages are committed lazily, so this is fine) +# +# The flag is passed through -C link-args so it works without a separate +# .cargo/config linker directive. It only affects the main thread; worker +# threads set their own size via std::thread::Builder::stack_size. +# ── Windows MSVC: fast linker (lld-link) ───────────────────────────────────── +# +# lld-link is LLVM's drop-in replacement for MSVC's link.exe. It is +# significantly faster for large Rust projects (the Windows release build +# spends most of its time linking). When LLVM is installed (winget or CI), +# lld-link.exe is available in the LLVM bin directory. +# +# The linker is set via CARGO_TARGET_*_LINKER in the CI workflow and in +# tauri-build.js (auto-detected). Do NOT hardcode it here because it +# breaks builds for developers who don't have LLVM installed — they +# fall back to the default MSVC link.exe which always works. +# +# Install: winget install LLVM.LLVM + +# Dynamic CRT: the binary links against vcruntime140.dll / msvcp140.dll. +# The NSIS installer bundles these DLLs app-locally (in $INSTDIR alongside +# skill.exe) so users don't need the VC++ Redistributable pre-installed. +# The installer also offers to download the full VC++ Redist as a fallback. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "link-args=/STACK:8388608"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "link-args=/STACK:8388608"] + +# ── Windows MinGW (x86_64-pc-windows-gnu): MinGW bash build script ──────────── +# +[target.x86_64-pc-windows-gnu] +rustflags = ["-C", "link-args=-Wl,--stack,8388608"] + +[target.i686-pc-windows-gnu] +rustflags = ["-C", "link-args=-Wl,--stack,8388608"] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..237b787f2 --- /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 new file mode 100644 index 000000000..81adfe384 --- /dev/null +++ b/.envrc @@ -0,0 +1,48 @@ +# direnv — https://direnv.net/ +# Run `direnv allow` once after cloning. +# +# On macOS: sets the default cargo target to Apple Silicon so that +# `cargo build`, `cargo check`, `cargo clippy` etc. all target +# aarch64-apple-darwin without an explicit --target flag. +# +# On Windows / Linux: CARGO_BUILD_TARGET is left unset, cargo uses the +# host triple (x86_64-pc-windows-msvc / x86_64-unknown-linux-gnu). + +if [ "$(uname)" = "Darwin" ]; then + export CARGO_BUILD_TARGET=aarch64-apple-darwin + + # Use GNU ar if available (Homebrew binutils) to suppress + # "illegal option -- D" warnings from Apple's system ar. + # Install: brew install binutils + GAR="$(brew --prefix binutils 2>/dev/null)/bin/gar" + 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). +# Run `npm run setup:llama-prebuilt` to download. +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 +if [ -f "scripts/ensure-rlx-models.sh" ]; then + bash scripts/ensure-rlx-models.sh +fi diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..9a212154f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,101 @@ +# ── Line endings ────────────────────────────────────────────────────────────── +# +# Normalise all text files to LF in the repository; Git converts to the +# platform native ending on checkout only when core.autocrlf is set. +# This prevents the "LF will be replaced by CRLF" warnings on Windows and +# keeps diffs clean regardless of the developer's OS. + +# Catch-all: treat everything Git auto-detects as text with LF in the repo. +* text=auto eol=lf + +# ── Source files (always LF) ────────────────────────────────────────────────── +*.rs text eol=lf +*.toml text eol=lf +*.lock text eol=lf +*.ts text eol=lf +*.svelte text eol=lf +*.js text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.json text eol=lf +*.json5 text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.sh text eol=lf +*.bash text eol=lf +*.env text eol=lf +*.envrc text eol=lf +*.gitignore text eol=lf +*.gitattributes text eol=lf +*.editorconfig text eol=lf + +# ── Config / build files ────────────────────────────────────────────────────── +Makefile text eol=lf +Dockerfile* text eol=lf +*.cmake text eol=lf +CMakeLists.txt text eol=lf +*.plist text eol=lf +*.entitlements text eol=lf + +# ── Windows-specific scripts (CRLF on checkout so cmd.exe / PowerShell work) ── +*.ps1 text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf + +# ── Binary files (no line-ending conversion, no diff) ──────────────────────── +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.icns binary +*.otf binary +*.ttf binary +*.woff binary +*.woff2 binary +*.glb binary +*.gltf binary +*.wav binary +*.mp3 binary +*.npy binary +*.safetensors binary +*.gguf binary +*.hnsw binary +*.sqlite binary +*.db binary +*.a binary +*.lib binary +*.dll binary +*.so binary +*.dylib binary +*.exe binary + +# ── Diff drivers ───────────────────────────────────────────────────────────── +# +# Tell git diff to use sensible language labels so hunks are easier to read +# in `git diff`, `git log -p`, and GitHub's PR view. + +*.rs diff=rust +*.toml diff=toml +*.ts diff=typescript +*.svelte diff=html +*.js diff=javascript +*.mjs diff=javascript +*.json diff=json +*.css diff=css +*.md diff=markdown +*.sh diff=bash +*.ps1 diff=powershell + +# ── Vendored TTS model bundle ──────────────────────────────────────────────── +# +# Store the committed Inflect-Nano bundle verbatim so its bytes match exactly +# what scripts/bundle-tts-weights.sh stages and the app loads at runtime — no +# line-ending conversion (the catch-all `* text=auto eol=lf` would otherwise +# rewrite the dictionary/JSON files) and no diff noise. +src-tauri/resources/tts/inflect-nano/** binary diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..ee657b6d7 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-only +# Copyright (C) 2026 NeuroSkill.com +# +# Minimal pre-commit hook — basic validation only. +# Full checks run in pre-push and CI. + +set -euo pipefail + +STAGED_FILES="$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true)" + +if [ -z "$STAGED_FILES" ]; then + exit 0 +fi + +# ── rlx source guard ────────────────────────────────────────────────────────── +# Commits must always carry the DEPLOYED rlx (crates.io); ../rlx is local-dev +# only. Safety net for when the Cargo.lock skip-worktree bit is bypassed (fresh +# clone, `git add -A`, GUI clients, etc. — see scripts/ensure-rlx.sh). +RLX_REGISTRY='^source = "registry+https://github.com/rust-lang/crates.io-index"' + +# 1) Cargo.toml must not reintroduce a ../rlx path patch (hard block). +if ! git diff --cached --quiet -- Cargo.toml 2>/dev/null; then + if git show :Cargo.toml 2>/dev/null | grep -vE '^[[:space:]]*#' | grep -qE 'path[[:space:]]*=[[:space:]]*"\.\./rlx'; then + echo "❌ Staged Cargo.toml adds a ../rlx path patch — that must stay local-only." >&2 + echo " Remove it and use 'scripts/rlx local' (writes an override OUTSIDE the repo)." >&2 + exit 1 + fi +fi + +# 2) Cargo.lock (as it will be committed = the index blob) must pin rlx to +# crates.io, not a local ../rlx checkout. Content-based, so it catches a +# locally-patched lock however it got staged (and even if HEAD is poisoned). +if git ls-files --error-unmatch Cargo.lock >/dev/null 2>&1; then + if ! git show :Cargo.lock 2>/dev/null | grep -A3 '^name = "rlx"$' | grep -q "$RLX_REGISTRY"; then + echo "❌ Cargo.lock pins rlx to a LOCAL ../rlx checkout, not crates.io — refusing to commit." >&2 + echo " ../rlx is local-dev only; CI and published builds resolve rlx from crates.io." >&2 + echo " Fix: run 'scripts/rlx deployed' (restores the registry lock), then commit." >&2 + echo " Run 'scripts/rlx local' afterward to resume local development." >&2 + exit 1 + fi +fi + +# Basic JSON validation for catalog files +if echo "$STAGED_FILES" | grep -q 'llm_catalog.json'; then + if ! python3 -m json.tool src-tauri/llm_catalog.json > /dev/null 2>&1; then + echo "❌ Invalid JSON in llm_catalog.json" + exit 1 + fi +fi + +# Basic i18n JSON validation +if echo "$STAGED_FILES" | grep -q '^src/lib/i18n/.*\.ts$'; then + echo "🌐 i18n files changed — running basic JSON validation…" + npm run -s check:i18n:locales || exit 1 +fi + +# Run cargo fmt on staged Rust files +if echo "$STAGED_FILES" | grep -q '\.rs$'; then + echo "🦀 Running cargo fmt…" + # 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)." +echo " Full checks will run on git push." diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..1bc7c2bb3 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-only +# Copyright (C) 2026 NeuroSkill.com +# +# Pre-push gate: scoped by default, full mode with PREPUSH_FULL=1. + +set -euo pipefail + +FULL_MODE="${PREPUSH_FULL:-0}" +CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" + +# Speed up local Rust builds if sccache is available. +if command -v sccache >/dev/null 2>&1; then + export RUSTC_WRAPPER=sccache + export CMAKE_C_COMPILER_LAUNCHER=sccache + export CMAKE_CXX_COMPILER_LAUNCHER=sccache +fi + +if [ "$FULL_MODE" = "1" ]; then + echo "🔎 Pre-push: full mode enabled (PREPUSH_FULL=1)" + + echo "🔎 Pre-push: changelog fragments" + if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + node scripts/check-changelog-fragments.js --base "HEAD~1" --head HEAD + else + node scripts/check-changelog-fragments.js --quiet + fi + + echo "🔎 Pre-push: frontend checks" + npm run -s check + npm test + + echo "🔎 Pre-push: cargo deny" + cargo deny check -A no-license-field -A parse-error -A license-not-encountered + + echo "🔎 Pre-push: Rust clippy" + cargo clippy --locked --workspace --exclude skill -- -D warnings + cargo clippy -p skill --locked -- -D warnings + + echo "🔎 Pre-push: Rust tests (all tiers)" + bash scripts/test-fast.sh --all + + echo "✅ Pre-push checks passed (full mode)." + exit 0 +fi + +BASE_REF="" +if git rev-parse --abbrev-ref --symbolic-full-name "@{upstream}" >/dev/null 2>&1; then + BASE_REF="$(git merge-base HEAD "@{upstream}")" +elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + BASE_REF="HEAD~1" +else + BASE_REF="HEAD" +fi + +CHANGED_FILES="$(git diff --name-only --diff-filter=d "$BASE_REF...HEAD" 2>/dev/null || true)" + +if [ -z "$CHANGED_FILES" ]; then + echo "✅ Pre-push checks passed (no changed files detected)." + exit 0 +fi + +NON_DOC_CHANGED="$(echo "$CHANGED_FILES" | grep -Ev '^(README\.md|CHANGELOG\.md|CONTRIBUTING\.md|AGENTS\.md|TODO\.md|changes/|docs/|\.github/)' || true)" +if [ -z "$NON_DOC_CHANGED" ]; then + echo "✅ Pre-push checks passed (docs/changelog-only changes)." + exit 0 +fi + +echo "🔎 Pre-push: scoped mode on branch '$CURRENT_BRANCH'" +echo "Changed files baseline: $BASE_REF" + +echo "🔎 Pre-push: changelog fragments" +node scripts/check-changelog-fragments.js --base "$BASE_REF" --head HEAD + +FRONTEND_CHANGED="$(echo "$CHANGED_FILES" | grep -E '^(src/|scripts/|package.json|package-lock.json|svelte.config.js|vite.config.js|tsconfig.json|biome.json)' || true)" +if [ -n "$FRONTEND_CHANGED" ]; then + echo "🔎 Pre-push: frontend checks (changed files)" + + FRONTEND_BIOME_FILES="$(echo "$FRONTEND_CHANGED" | grep -E '\.(ts|js|svelte|json)$' || true)" + if [ -n "$FRONTEND_BIOME_FILES" ]; then + # shellcheck disable=SC2086 + npx biome check --no-errors-on-unmatched $FRONTEND_BIOME_FILES + fi + + VITEST_RELATED_FILES="$(echo "$FRONTEND_CHANGED" | grep -E '^src/.*\.(ts|js|svelte)$' || true)" + if [ -n "$VITEST_RELATED_FILES" ]; then + # shellcheck disable=SC2086 + npx vitest related --run --exclude 'src/tests/*-e2e*' $VITEST_RELATED_FILES + fi + + # Guard/meta tests read source files via fs, not import — vitest related + # can't detect the dependency. Run them when daemon or scripts change. + DAEMON_OR_SCRIPTS="$(echo "$FRONTEND_CHANGED" | grep -E '^(src/lib/daemon/|scripts/)' || true)" + if [ -n "$DAEMON_OR_SCRIPTS" ]; then + npx vitest run src/tests/daemon-client.test.ts + fi +fi + +# CI script self-test — catches broken commands before they hit CI +CI_CHANGED="$(echo "$CHANGED_FILES" | grep -E '^scripts/ci\.mjs$|^\.github/workflows/' || true)" +if [ -n "$CI_CHANGED" ]; then + echo "🔎 Pre-push: CI script self-test" + node scripts/ci.mjs self-test +fi + +RUST_CHANGED="$(echo "$CHANGED_FILES" | grep -E '\.rs$|^Cargo.toml$|^Cargo.lock$|^src-tauri/Cargo.toml$|^crates/.*/Cargo.toml$' || true)" +if [ -n "$RUST_CHANGED" ]; then + echo "🔎 Pre-push: Rust checks (changed crates)" + cargo deny check -A no-license-field -A parse-error -A license-not-encountered + cargo fmt --check + + declare -a CRATES=() + NEED_SKILL=0 + + while IFS= read -r f; do + [ -z "$f" ] && continue + case "$f" in + crates/*) + crate_dir="${f#crates/}" + crate_name="${crate_dir%%/*}" + if [[ "$crate_name" == skill-* ]]; then + CRATES+=("-p" "$crate_name") + fi + ;; + src-tauri/*) + NEED_SKILL=1 + ;; + Cargo.toml|Cargo.lock) + NEED_SKILL=1 + ;; + esac + done <<< "$CHANGED_FILES" + + # Deduplicate crate flags (bash 3 compatible) + if [ "${#CRATES[@]}" -gt 0 ]; then + uniq_pairs="$(printf '%s\n' "${CRATES[@]}" | awk 'NR%2{p=$0;next}{print p" "$0}' | sort -u)" + CRATES=() + while IFS= read -r pair; do + [ -z "$pair" ] && continue + CRATES+=("${pair%% *}" "${pair#* }") + done <- + Build the workspace against MIT-RLX/rlx + rlx-models from git instead of + crates.io. Each repo is a STANDALONE unit: its ref is resolved to a commit SHA + (cheap `git ls-remote`, no clone) and cached under its OWN key, so a push to + rlx-models never invalidates the rlx cache (and vice-versa). Only the changed + repo re-clones. After both are present, wires a cargo `[patch.crates-io]` + override and refreshes Cargo.lock so `--locked` build/test steps still pass. + + Both repos are public, so no token is needed. Default ref is `main` (the most + recent commit); the resolved SHA feeds each cache key, so unchanged upstream = + instant restore. Pin a job by passing rlx-ref / rlx-models-ref a tag or SHA, + or set enabled=false to fall back to the crates.io build. + +inputs: + rlx-ref: + description: git ref (branch, tag, or full SHA) for MIT-RLX/rlx. + required: false + default: main + rlx-models-ref: + description: git ref (branch, tag, or full SHA) for MIT-RLX/rlx-models. + required: false + default: main + enabled: + description: When "false", no-op and build rlx/rlx-models from crates.io. + required: false + default: "true" + +runs: + using: composite + steps: + # ── Resolve refs → commit SHAs ──────────────────────────────────────────── + # `git ls-remote` is a metadata-only call (no objects), so this is where each + # cache key comes from without paying for a clone. A full SHA passed as the + # ref won't appear in ls-remote output, so we fall back to using it verbatim. + - name: Resolve RLX commit SHAs + if: inputs.enabled == 'true' + id: rev + shell: bash + run: | + set -euo pipefail + resolve() { # $1 url $2 ref -> full SHA + local sha + sha="$(git ls-remote "$1" "$2" | awk 'NR==1{print $1}')" + [ -n "$sha" ] || sha="$2" + printf '%s' "$sha" + } + rlx_sha="$(resolve https://github.com/MIT-RLX/rlx.git '${{ inputs.rlx-ref }}')" + mdl_sha="$(resolve https://github.com/MIT-RLX/rlx-models.git '${{ inputs.rlx-models-ref }}')" + + # Check the shallow clones out NEXT TO the workspace, so the cargo + # [patch] override (written to ../.cargo/config.toml by ensure-rlx.sh) + # is discovered as an ancestor config — the same layout local dev uses. + # Normalise to a mixed path (C:/…) on Windows so git-bash, cargo, and + # actions/cache all agree; a plain POSIX path elsewhere. + ws="$GITHUB_WORKSPACE" + if command -v cygpath >/dev/null 2>&1; then ws="$(cygpath -u "$GITHUB_WORKSPACE")"; fi + parent="$(cd "$ws/.." && pwd)" + if command -v cygpath >/dev/null 2>&1; then parent="$(cygpath -m "$parent")"; fi + + { + echo "rlx_sha=$rlx_sha" + echo "mdl_sha=$mdl_sha" + echo "rlx_dir=$parent/rlx" + echo "mdl_dir=$parent/rlx-models" + } >> "$GITHUB_OUTPUT" + echo "rlx @ $rlx_sha (${{ inputs.rlx-ref }})" + echo "rlx-models @ $mdl_sha (${{ inputs.rlx-models-ref }})" + + # ── rlx: standalone cache, keyed on rlx's SHA alone ─────────────────────── + - name: Restore rlx source cache + if: inputs.enabled == 'true' + id: cache_rlx + uses: actions/cache@v5 + with: + path: ${{ steps.rev.outputs.rlx_dir }} + key: rlx-src-${{ runner.os }}-${{ steps.rev.outputs.rlx_sha }} + + - name: Shallow-clone rlx (cache miss) + if: inputs.enabled == 'true' && steps.cache_rlx.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + dst='${{ steps.rev.outputs.rlx_dir }}' + rm -rf "$dst" + git init -q "$dst" + git -C "$dst" remote add origin https://github.com/MIT-RLX/rlx.git + git -C "$dst" -c protocol.version=2 fetch -q --depth 1 origin '${{ steps.rev.outputs.rlx_sha }}' + git -C "$dst" checkout -q --detach FETCH_HEAD + rm -rf "$dst/.git" # history not needed; keeps the cache tiny + echo "cloned rlx -> $dst" + + # ── rlx-models: standalone cache, keyed on rlx-models' SHA alone ────────── + - name: Restore rlx-models source cache + if: inputs.enabled == 'true' + id: cache_mdl + uses: actions/cache@v5 + with: + path: ${{ steps.rev.outputs.mdl_dir }} + key: rlx-models-src-${{ runner.os }}-${{ steps.rev.outputs.mdl_sha }} + + - name: Shallow-clone rlx-models (cache miss) + if: inputs.enabled == 'true' && steps.cache_mdl.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + dst='${{ steps.rev.outputs.mdl_dir }}' + rm -rf "$dst" + git init -q "$dst" + git -C "$dst" remote add origin https://github.com/MIT-RLX/rlx-models.git + git -C "$dst" -c protocol.version=2 fetch -q --depth 1 origin '${{ steps.rev.outputs.mdl_sha }}' + git -C "$dst" checkout -q --detach FETCH_HEAD + rm -rf "$dst/.git" # history not needed; keeps the cache tiny + echo "cloned rlx-models -> $dst" + + # ── Wire the [patch] override + refresh Cargo.lock ──────────────────────── + # Runs once, after BOTH checkouts exist (ensure-rlx.sh writes a single + # [patch.crates-io] block spanning both repos and refreshes the lock once). + # RLX_CI_PATCH=1 tells ensure-rlx.sh to skip its "CI uses crates.io" + # short-circuit and run `cargo metadata` (no --locked) so the on-disk + # Cargo.lock matches the patched sources before any --locked cargo step. + - name: Wire cargo [patch] override + if: inputs.enabled == 'true' + shell: bash + env: + RLX_CI_PATCH: "1" + RLX_ROOT: ${{ steps.rev.outputs.rlx_dir }} + RLX_MODELS_ROOT: ${{ steps.rev.outputs.mdl_dir }} + run: bash scripts/ensure-rlx.sh + + - name: RLX from crates.io (disabled) + if: inputs.enabled != 'true' + shell: bash + run: echo "checkout-rlx enabled=false - building rlx/rlx-models from crates.io" diff --git a/.github/actions/setup-onnxruntime-linux/action.yml b/.github/actions/setup-onnxruntime-linux/action.yml new file mode 100644 index 000000000..18534ce73 --- /dev/null +++ b/.github/actions/setup-onnxruntime-linux/action.yml @@ -0,0 +1,25 @@ +name: Setup ONNX Runtime (Linux x64) +description: Cache and install ONNX Runtime official Linux x64 binaries and export ORT env vars + +inputs: + version: + description: ONNX Runtime version + required: false + default: "1.23.2" + cache-key-prefix: + description: Cache key prefix + required: false + default: "linux-onnxruntime" + +runs: + using: composite + steps: + - name: Cache ONNX Runtime (Linux x64) + uses: actions/cache@v5 + with: + path: ~/.cache/onnxruntime + key: ${{ inputs.cache-key-prefix }}-${{ inputs.version }}-x64 + + - name: Install ONNX Runtime (Linux x64) + export ORT env + shell: bash + run: ORT_VERSION="${{ inputs.version }}" bash scripts/install-onnxruntime-linux.sh diff --git a/.github/actions/setup-onnxruntime-windows/action.yml b/.github/actions/setup-onnxruntime-windows/action.yml new file mode 100644 index 000000000..514a595ff --- /dev/null +++ b/.github/actions/setup-onnxruntime-windows/action.yml @@ -0,0 +1,76 @@ +name: Setup ONNX Runtime (Windows x64) +description: Cache and install ONNX Runtime official Windows x64 binaries and export ORT env vars + +inputs: + version: + description: ONNX Runtime version + required: false + default: "1.23.2" + cache-key-prefix: + description: Cache key prefix + required: false + default: "windows-onnxruntime" + +runs: + using: composite + steps: + - name: Resolve ONNX cache dir + id: ort-cache + shell: powershell + run: | + $dir = Join-Path $env:USERPROFILE ".cache\onnxruntime" + "dir=$dir" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Cache ONNX Runtime (Windows x64) + uses: actions/cache@v5 + with: + path: ${{ steps.ort-cache.outputs.dir }} + key: ${{ inputs.cache-key-prefix }}-${{ inputs.version }}-x64 + + - name: Install ONNX Runtime (Windows x64) + export ORT env + shell: powershell + run: | + $OrtVersion = "${{ inputs.version }}" + $OrtBase = Join-Path $env:USERPROFILE ".cache\onnxruntime" + $OrtRoot = Join-Path $OrtBase ("onnxruntime-win-x64-" + $OrtVersion) + $OrtDll = Join-Path $OrtRoot "lib\onnxruntime.dll" + $OrtLib = Join-Path $OrtRoot "lib\onnxruntime.lib" + + if (-not ((Test-Path $OrtDll) -and (Test-Path $OrtLib))) { + New-Item -ItemType Directory -Force -Path $OrtBase | Out-Null + $ZipPath = Join-Path $env:RUNNER_TEMP "onnxruntime-win-x64.zip" + $Url = "https://github.com/microsoft/onnxruntime/releases/download/v$OrtVersion/onnxruntime-win-x64-$OrtVersion.zip" + + $ok = $false + for ($i = 1; $i -le 8; $i++) { + try { + Invoke-WebRequest -Uri $Url -OutFile $ZipPath + $ok = $true + break + } catch { + if ($i -eq 8) { throw } + Start-Sleep -Seconds 2 + } + } + if (-not $ok) { + Write-Error "Failed to download ONNX Runtime after retries" + exit 1 + } + + Remove-Item -Recurse -Force $OrtRoot -ErrorAction SilentlyContinue + Expand-Archive -Path $ZipPath -DestinationPath $OrtBase -Force + } + + if (-not ((Test-Path $OrtDll) -and (Test-Path $OrtLib))) { + Write-Error "ONNX Runtime files missing: $OrtDll / $OrtLib" + exit 1 + } + + "ORT_LIB_LOCATION=$OrtRoot\lib" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ORT_PREFER_DYNAMIC_LINK=1" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "PATH=$OrtRoot\lib;$OrtRoot\bin;$env:PATH" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + Write-Host "[ok] ORT_LIB_LOCATION=$OrtRoot\lib" diff --git a/.github/actions/setup-rust-bootstrap-linux/action.yml b/.github/actions/setup-rust-bootstrap-linux/action.yml new file mode 100644 index 000000000..ef8b5002c --- /dev/null +++ b/.github/actions/setup-rust-bootstrap-linux/action.yml @@ -0,0 +1,38 @@ +name: Setup Rust bootstrap (Linux) +description: Install Rust toolchain and configure sccache for Linux jobs + +inputs: + targets: + description: Optional rustup targets (comma-separated or single target) + required: false + default: "" + use-sccache: + description: Whether to install/configure sccache action + required: false + default: "true" + +runs: + using: composite + steps: + - name: Install Rust stable (default target) + if: inputs.targets == '' + uses: dtolnay/rust-toolchain@stable + + - name: Install Rust stable (custom targets) + if: inputs.targets != '' + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.targets }} + + - name: Setup sccache + if: inputs.use-sccache == 'true' + uses: Mozilla-Actions/sccache-action@v0.0.9 + + - name: Configure sccache (GitHub Actions backend) + if: inputs.use-sccache == 'true' + shell: bash + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=10G" >> "$GITHUB_ENV" + # Bump to invalidate all remote sccache entries after major toolchain changes. + echo "SCCACHE_GHA_VERSION=1" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-vulkan-linux/action.yml b/.github/actions/setup-vulkan-linux/action.yml new file mode 100644 index 000000000..790fe7e36 --- /dev/null +++ b/.github/actions/setup-vulkan-linux/action.yml @@ -0,0 +1,22 @@ +name: Setup Vulkan SDK (Linux) +description: Add LunarG apt repository for Vulkan SDK availability + +runs: + using: composite + steps: + # Add the LunarG apt repository so vulkan-sdk is available to apt. + # The actual package install is handled by the caller's cache-apt-pkgs-action + # step (vulkan-sdk must be included in that step's package list). + - name: Add LunarG Vulkan apt repository + shell: bash + run: | + set -eo pipefail + CODENAME="$(lsb_release -cs 2>/dev/null || (source /etc/os-release && echo "${VERSION_CODENAME:-noble}"))" + if [[ ! -f "/etc/apt/sources.list.d/lunarg-vulkan-${CODENAME}.list" ]]; then + curl -fsSL https://packages.lunarg.com/lunarg-signing-key-pub.asc \ + | sudo tee /etc/apt/trusted.gpg.d/lunarg.asc > /dev/null + sudo curl -fsSLo \ + "/etc/apt/sources.list.d/lunarg-vulkan-${CODENAME}.list" \ + "https://packages.lunarg.com/vulkan/lunarg-vulkan-${CODENAME}.list" + sudo apt-get update -y -q + fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..f6c3fc069 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,43 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps(npm)" + groups: + npm-all: + patterns: + - "*" + ignore: + # typescript is capped at 6.x: @sveltejs/kit's peer range is + # "^5.3.3 || ^6.0.0", so a major bump to 7.x breaks a fresh + # `npm install` with ERESOLVE (see issue #81, regression from #75). + # Drop this once kit widens its peerDependencies to include TS 7. + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] + + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:15" + timezone: "UTC" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps(cargo)" + groups: + cargo-all: + patterns: + - "*" + ignore: + # wgpu cannot be upgraded independently: zuna-rs and luna-rs require wgpu 26.x. + # Upgrade wgpu together with the encoder crates. + - dependency-name: "wgpu" + versions: [">=27"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adb589609..6ab4b447f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,128 +7,1145 @@ on: branches: [main, develop] # Allow manual re-runs from the Actions tab on any branch. workflow_dispatch: + inputs: + run_all_tests: + description: "Run ALL crate tests (skip change detection)" + type: boolean + default: false + run_llm_e2e: + description: "Run LLM E2E integration test" + type: boolean + default: false + run_lsl_e2e: + description: "Run LSL E2E integration test (32-ch 256 Hz pipeline, ~30 s)" + type: boolean + default: false + run_coverage: + description: "Run Rust code coverage (cargo-llvm-cov, ~20 min)" + type: boolean + default: false + coverage_min_lines: + description: "Minimum line coverage percentage gate" + type: string + default: '55' + +# Cancel superseded runs on the same branch / PR — avoids wasting runner +# minutes when multiple commits are pushed in quick succession. +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +# Opt into Node.js 24 now so we surface any action incompatibilities before +# GitHub forces the switch on June 2, 2026. +# ilammy/msvc-dev-cmd, KyleMayes/install-llvm-action and +# Mozilla-Actions/sccache-action are all still on node20 in their latest +# releases — update these pins when node24 builds ship. +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: + changelog-gate: + name: Changelog fragments gate + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Validate changelog fragment format (if present) + run: | + node scripts/check-changelog-fragments.js \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha }}" + # ── Rust backend ───────────────────────────────────────────────────────────── + # + # clippy is a strict superset of `cargo check` — it performs the same + # compilation AND runs lint passes. Running both wastes a full compile + # cycle (~3-5 min). We therefore run clippy only. + # + # `--workspace --exclude skill` covers all library crates without the + # espeak/Vulkan deps; a second clippy pass covers the app crate with + # those features enabled. + # + # mold (fast linker) is installed to match the release-linux workflow and + # shave 30-60 s off every link-heavy operation. rust-check: - name: Rust — cargo check + clippy + name: Rust — clippy + test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # ── Free pre-installed software we don't use (~10 GB reclaimed) ────────── + # ubuntu-latest ships Android SDK, .NET, Swift, Haskell, CodeQL, large + # Docker layers, and mono — none of which this project needs. + # Without this, the Rust build cache restore + compile fills the ~14 GB + # runner disk and causes "No space left on device" failures. + - name: Free disk space + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ + /usr/local/share/powershell /usr/local/share/chromium /usr/share/swift \ + /opt/hostedtoolcache/CodeQL + sudo docker image prune -af 2>/dev/null || true + df -h / - - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - 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 - - name: Cache Cargo - uses: actions/cache@v4 + # Swatinem/rust-cache gives smarter per-crate invalidation and automatic + # pruning vs manual actions/cache — matches the release workflows. + - name: Cache Cargo registry + build artifacts + uses: Swatinem/rust-cache@v2.9.1 with: - path: | - ~/.cargo/registry - ~/.cargo/git - src-tauri/target - key: linux-cargo-${{ hashFiles('src-tauri/Cargo.lock') }} - restore-keys: linux-cargo- + workspaces: ". -> src-tauri/target" + shared-key: linux-ci-x86_64-unknown-linux-gnu-v1 + cache-targets: false + cache-on-failure: false - - name: Cache apt packages - uses: actions/cache@v4 + - name: Setup Vulkan SDK repository + uses: ./.github/actions/setup-vulkan-linux + + - name: Cache + install Tauri system deps (incl. mold + clang + Vulkan) + uses: awalsh128/cache-apt-pkgs-action@v1 with: - path: /var/cache/apt/archives - # Bust the cache when the package list changes (workflow file edit). - key: apt-${{ runner.os }}-tauri-${{ hashFiles('.github/workflows/ci.yml') }} - restore-keys: apt-${{ runner.os }}-tauri- + packages: >- + libwebkit2gtk-4.1-dev libappindicator3-dev + librsvg2-dev patchelf libssl-dev + libudev-dev libdbus-1-dev pkg-config + libespeak-ng-dev libasound2-dev + libpipewire-0.3-dev libgbm-dev + cmake binutils mold clang + protobuf-compiler libopenblas-dev + vulkan-sdk + version: tauri-rust-check-v7 - - name: Install Tauri system deps + # clippy (workspace crates — no espeak/Vulkan deps needed) + - name: cargo clippy (workspace crates) + env: + RUSTC_WRAPPER: sccache + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=mold + shell: bash run: | - sudo apt-get update -y - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev libappindicator3-dev \ - librsvg2-dev patchelf libssl-dev \ - libudev-dev libdbus-1-dev pkg-config \ - libespeak-ng-dev libasound2-dev \ - cmake binutils + set -euo pipefail + run_cmd() { + cargo clippy --locked --target x86_64-unknown-linux-gnu \ + --workspace --exclude skill --exclude skill-router \ + -- -D warnings + cargo clippy --locked --target x86_64-unknown-linux-gnu \ + --no-default-features -p skill-router --features cpu \ + -- -D warnings + } + run_cmd - - name: Cache espeak-ng static lib - id: cache-espeak - uses: actions/cache@v4 - with: - path: | - src-tauri/espeak-static - src-tauri/resources/espeak-ng-data - key: linux-x86_64-espeak-${{ hashFiles('scripts/build-espeak-static.sh') }} + # clippy (app crate — needs espeak + Vulkan) + - name: cargo clippy (app) + env: + RUSTC_WRAPPER: sccache + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=mold + # Cache cmake-based -sys crate builds (espeak-ng-sys, etc.) + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache + shell: bash + run: | + set -euo pipefail + run_cmd() { + cargo clippy -p skill --locked --target x86_64-unknown-linux-gnu -- -D warnings + } + run_cmd - - name: Build espeak-ng static lib - if: steps.cache-espeak.outputs.cache-hit != 'true' - run: bash scripts/build-espeak-static.sh + # Determine which crates need testing based on changed files. + # On PRs: compare against the base branch. + # On pushes to main/develop: compare against the previous commit. + # On manual dispatch: test everything. + - name: Determine changed crates + id: changed + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE="${{ github.event.pull_request.base.sha }}" + elif [[ "${{ github.event_name }}" == "push" ]]; then + BASE="${{ github.event.before }}" + else + BASE="" + fi - - name: cargo check - working-directory: src-tauri - run: cargo check --locked + ALL_CRATE_FLAGS="-p skill-eeg -p skill-data -p skill-constants -p skill-tools -p skill-devices -p skill-settings -p skill-history -p skill-health -p skill-router -p skill-llm -p skill-autostart -p skill-tts -p skill-gpu -p skill-headless -p skill-label-index -p skill-skills -p skill-jobs -p skill-commands -p skill-exg" - - name: cargo clippy - working-directory: src-tauri - run: cargo clippy --locked -- -D warnings + if [[ "${{ inputs.run_all_tests }}" == "true" ]]; then + # Manual dispatch with run_all_tests — test everything + CRATE_FLAGS="$ALL_CRATE_FLAGS" + elif [[ -z "$BASE" || "$BASE" == "0000000000000000000000000000000000000000" ]]; then + # First push or manual dispatch — test everything + CRATE_FLAGS="$ALL_CRATE_FLAGS" + else + CRATE_FLAGS=$(bash scripts/changed-crates.sh "$BASE") + fi + + echo "crate_flags=$CRATE_FLAGS" >> "$GITHUB_OUTPUT" + if [[ -z "$CRATE_FLAGS" || "$CRATE_FLAGS" == "# "* ]]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::notice::No testable crates affected — skipping cargo test" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "::notice::Testing (unit): $CRATE_FLAGS" + fi + + # Filter for crates that have integration tests (tests/*.rs). + # skill-llm E2E is excluded — it's gated by manual dispatch. + INT_CRATES="skill-eeg skill-data skill-headless skill-jobs skill-history skill-settings skill-router skill-label-index skill-skills skill-tools skill-commands skill-llm" + INT_FLAGS="" + for c in $INT_CRATES; do + if echo "$CRATE_FLAGS" | grep -qw "\-p $c"; then + INT_FLAGS="$INT_FLAGS -p $c" + fi + done + INT_FLAGS="$(echo "$INT_FLAGS" | xargs)" # trim + + if [[ -n "$INT_FLAGS" ]]; then + echo "int_flags=$INT_FLAGS" >> "$GITHUB_OUTPUT" + echo "skip_int=false" >> "$GITHUB_OUTPUT" + echo "::notice::Testing (integration): $INT_FLAGS" + else + echo "int_flags=" >> "$GITHUB_OUTPUT" + echo "skip_int=true" >> "$GITHUB_OUTPUT" + echo "::notice::No integration-testable crates affected" + fi + + if echo "$CRATE_FLAGS" | grep -qw "\-p skill-router"; then + echo "test_router=true" >> "$GITHUB_OUTPUT" + else + echo "test_router=false" >> "$GITHUB_OUTPUT" + fi + + ROUTERLESS_FLAGS="$(echo "$CRATE_FLAGS" | sed 's/-p skill-router//g' | xargs || true)" + echo "routerless_flags=$ROUTERLESS_FLAGS" >> "$GITHUB_OUTPUT" + + ROUTERLESS_INT="$(echo "$INT_FLAGS" | sed 's/-p skill-router//g' | xargs || true)" + echo "routerless_int_flags=$ROUTERLESS_INT" >> "$GITHUB_OUTPUT" + + - name: cargo test (affected crates — unit) + id: rust_unit_tests + if: steps.changed.outputs.skip != 'true' + env: + RUSTC_WRAPPER: sccache + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=mold + shell: bash + run: | + set -euo pipefail + start_ts="$(date +%s)" + run_cmd() { + local flags="$1" + [[ -z "$flags" ]] && return 0 + cargo test --locked --target x86_64-unknown-linux-gnu \ + $flags \ + --lib + } + run_router_cmd() { + cargo test --locked --target x86_64-unknown-linux-gnu \ + --no-default-features -p skill-router --features cpu \ + --lib + } + run_all() { + run_cmd "${{ steps.changed.outputs.routerless_flags }}" + if [[ "${{ steps.changed.outputs.test_router }}" == "true" ]]; then + run_router_cmd + fi + } + run_all + end_ts="$(date +%s)" + echo "duration_sec=$((end_ts - start_ts))" >> "$GITHUB_OUTPUT" + + - name: cargo test (affected crates — integration) + id: rust_integration_tests + if: steps.changed.outputs.skip_int != 'true' + env: + RUSTC_WRAPPER: sccache + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=mold + shell: bash + run: | + set -euo pipefail + start_ts="$(date +%s)" + run_cmd() { + local flags="$1" + [[ -z "$flags" ]] && return 0 + cargo test --locked --target x86_64-unknown-linux-gnu \ + $flags \ + --test '*' + } + run_router_cmd() { + cargo test --locked --target x86_64-unknown-linux-gnu \ + --no-default-features -p skill-router --features cpu \ + --test '*' + } + run_all() { + run_cmd "${{ steps.changed.outputs.routerless_int_flags }}" + if [[ "${{ steps.changed.outputs.test_router }}" == "true" ]]; then + run_router_cmd + fi + } + run_all + end_ts="$(date +%s)" + echo "duration_sec=$((end_ts - start_ts))" >> "$GITHUB_OUTPUT" + + - name: Collect Rust health metrics + if: always() + shell: bash + run: | + set -euo pipefail + mkdir -p ci-metrics + UNIT_SEC="${{ steps.rust_unit_tests.outputs.duration_sec || '0' }}" + INT_SEC="${{ steps.rust_integration_tests.outputs.duration_sec || '0' }}" + + { + echo '{' + echo ' "job": "rust-check",' + echo ' "unit_test_duration_sec":' "$UNIT_SEC" ',' + echo ' "integration_test_duration_sec":' "$INT_SEC" + echo '}' + } > ci-metrics/rust-check.json + + { + echo "## Rust health metrics" + echo "- Unit test duration: ${UNIT_SEC}s" + echo "- Integration test duration: ${INT_SEC}s" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Capture sccache stats + if: always() + shell: bash + run: | + mkdir -p ci-metrics + sccache --show-stats > ci-metrics/rust-sccache-stats.txt || true + + - name: Summarize sccache hit rate (Rust job) + if: always() + shell: bash + run: | + STATS=ci-metrics/rust-sccache-stats.txt + if [[ ! -f "$STATS" ]]; then + echo "- sccache stats: unavailable" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + TOTAL_HIT_RATE="$(awk '/^Cache hits rate[[:space:]]/{print $(NF-1)" "$NF; exit}' "$STATS")" + RUST_HIT_RATE="$(awk '/^Cache hits rate \(Rust\)/{print $(NF-1)" "$NF; exit}' "$STATS")" + CACHE_LOCATION="$(awk -F'Cache location[[:space:]]+' '/^Cache location/{print $2; exit}' "$STATS")" + { + echo "## sccache metrics (Rust job)" + echo "- Cache location: ${CACHE_LOCATION:-unknown}" + echo "- Total hit rate: ${TOTAL_HIT_RATE:-unknown}" + echo "- Rust hit rate: ${RUST_HIT_RATE:-unknown}" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Rust health metrics + if: always() + uses: actions/upload-artifact@v7 + with: + name: ci-health-rust-check + path: ci-metrics/ + retention-days: 30 # ── Frontend ───────────────────────────────────────────────────────────────── frontend-check: name: Frontend — check + i18n + tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + submodules: recursive - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' + - name: Update npm to latest + run: npm install -g npm@latest + + - name: Cache node_modules + id: cache-node-modules + uses: actions/cache@v5 + with: + path: node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + # Retry handles transient ETXTBSY from esbuild postinstall on Linux. - name: Install dependencies - run: npm ci + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci || npm ci # Run i18n sync first — it's cheap and catches missing translation keys # before the slower svelte-check. - name: i18n sync check run: npm run sync:i18n:check + - name: i18n audit check + run: npm run audit:i18n:check + + - name: i18n locales check (all non-en locales) + run: npm run -s check:i18n:locales + + # Biome — format check (fast, ~100ms; lint is advisory via warnings) + - name: Biome format check + run: npx biome format src/ scripts/ + + - name: Biome lint + run: npx biome lint src/ scripts/ + + - name: Daemon thin-client invoke guard + run: npm run -s check:daemon-invokes + - name: svelte-check - run: npm run check + id: frontend_svelte_check + shell: bash + run: | + set -euo pipefail + start_ts="$(date +%s)" + npm run check + end_ts="$(date +%s)" + echo "duration_sec=$((end_ts - start_ts))" >> "$GITHUB_OUTPUT" - name: Unit tests - run: npm test + id: frontend_unit_tests + shell: bash + run: | + set -euo pipefail + start_ts="$(date +%s)" + npm test + end_ts="$(date +%s)" + echo "duration_sec=$((end_ts - start_ts))" >> "$GITHUB_OUTPUT" + + - name: Collect Frontend health metrics + if: always() + shell: bash + run: | + set -euo pipefail + mkdir -p ci-metrics + CHECK_SEC="${{ steps.frontend_svelte_check.outputs.duration_sec || '0' }}" + TEST_SEC="${{ steps.frontend_unit_tests.outputs.duration_sec || '0' }}" + + { + echo '{' + echo ' "job": "frontend-check",' + echo ' "svelte_check_duration_sec":' "$CHECK_SEC" ',' + echo ' "unit_test_duration_sec":' "$TEST_SEC" + echo '}' + } > ci-metrics/frontend-check.json + + { + echo "## Frontend health metrics" + echo "- svelte-check duration: ${CHECK_SEC}s" + echo "- Unit test duration: ${TEST_SEC}s" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Frontend health metrics + if: always() + uses: actions/upload-artifact@v7 + with: + name: ci-health-frontend-check + path: ci-metrics/ + retention-days: 30 + + # ── Windows Rust backend ───────────────────────────────────────────────────── + # + # Same optimisation as Linux: clippy replaces check (superset), removing + # a redundant full compilation pass. + windows-check: + name: Windows — cargo clippy + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + + - name: Validate Windows app manifest + shell: bash + run: node scripts/check-windows-manifest.mjs src-tauri/manifest.xml + + # Puts cl.exe, link.exe, lib.exe, and signtool.exe in PATH. + # Required for the espeak-ng static library build (lib.exe) and for + # the Vulkan SDK CMake integration used by wgpu/rlx-gpu features. + - name: Set up MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + + - name: Validate Windows app manifest with mt.exe + shell: powershell + run: | + $mt = Get-Command mt.exe -ErrorAction SilentlyContinue + if (-not $mt) { + Write-Error "mt.exe not found in PATH" + exit 1 + } + & $mt.Source -nologo -manifest src-tauri/manifest.xml -validate_manifest + if ($LASTEXITCODE -ne 0) { + Write-Error "mt.exe manifest validation failed" + exit $LASTEXITCODE + } + Write-Host "[ok] mt.exe manifest validation passed" + + # Git for Windows ships a Unix hard-link utility at + # C:\Program Files\Git\usr\bin prepends two problematic binaries to PATH + # at every step (GITHUB_PATH is re-applied each step so runtime deletions + # don't survive, but file-level deletions do): + # + # link.exe — silently shadows MSVC's linker → build picks wrong linker + # tar.exe — GNU tar used by actions/cache post-cleanup with --posix, + # which restricts filenames to POSIX-portable characters and + # corrupts / drops Windows paths in the Rust build cache. + # Removing it forces actions/cache to use the built-in + # C:\Windows\System32\tar.exe (bsdtar 3.x) instead. + - name: Remove Git's link.exe and tar.exe (shadow MSVC linker / corrupt cache) + shell: powershell + run: | + foreach ($bin in @("link.exe", "tar.exe")) { + $path = "C:\Program Files\Git\usr\bin\$bin" + if (Test-Path $path) { + Remove-Item $path -Force + Write-Host "[ok] Deleted $path" + } else { + Write-Host "[skip] $path not found" + } + } + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + # Swatinem/rust-cache is used here (matching the release workflow) for + # smarter per-crate invalidation vs the manual actions/cache approach. + - name: Cache Cargo registry + build artifacts + uses: Swatinem/rust-cache@v2.9.1 + with: + workspaces: ". -> src-tauri/target" + shared-key: windows-ci-x86_64-pc-windows-msvc-v1 + cache-targets: false + cache-on-failure: false + + # ── Build cache (sccache) ─────────────────────────────────────────────── + - name: Setup sccache + uses: Mozilla-Actions/sccache-action@v0.0.9 + + - name: Configure sccache for GitHub Actions cache + shell: bash + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=10G" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=1" >> "$GITHUB_ENV" + + # Ensure cargo.exe can always resolve sccache on Windows. + if [ -n "${SCCACHE_PATH:-}" ]; then + SCCACHE_DIR="$(dirname "$SCCACHE_PATH")" + if [ -f "$SCCACHE_DIR/sccache.exe" ]; then + SCCACHE_NATIVE="$(cygpath -w "$SCCACHE_DIR/sccache.exe")" + echo "RUSTC_WRAPPER=$SCCACHE_NATIVE" >> "$GITHUB_ENV" + echo "$SCCACHE_DIR" >> "$GITHUB_PATH" + echo "[ok] RUSTC_WRAPPER=$SCCACHE_NATIVE" + elif [ -f "$SCCACHE_PATH.exe" ]; then + SCCACHE_NATIVE="$(cygpath -w "$SCCACHE_PATH.exe")" + echo "RUSTC_WRAPPER=$SCCACHE_NATIVE" >> "$GITHUB_ENV" + echo "$(dirname "$SCCACHE_PATH.exe")" >> "$GITHUB_PATH" + echo "[ok] RUSTC_WRAPPER=$SCCACHE_NATIVE" + else + echo "[warn] sccache binary not found via SCCACHE_PATH=$SCCACHE_PATH" + fi + else + echo "[warn] SCCACHE_PATH not set; relying on PATH resolution" + fi + + - name: Install protoc (Windows) + run: node scripts/ci.mjs install-protoc-windows + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Update npm to latest + run: npm install -g npm@latest + + # Retry handles transient ETXTBSY from esbuild postinstall on Linux. + - name: Install JS dependencies + run: npm ci || npm ci + + # Catch PowerShell encoding bugs (e.g. UTF-8 multi-byte sequences that + # Windows-1252-reading PS5.1 misinterprets as string delimiters) before + # they break the build. Uses the PS parser directly — no execution needed. + - name: Validate PowerShell scripts (syntax check) + shell: powershell + run: | + $failed = $false + Get-ChildItem scripts\*.ps1 | ForEach-Object { + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile( + $_.FullName, [ref]$null, [ref]$parseErrors + ) + if ($parseErrors.Count -gt 0) { + Write-Host "::error file=$($_.Name)::$($parseErrors.Count) parse error(s):" + $parseErrors | ForEach-Object { Write-Host " $_" } + $failed = $true + } else { + Write-Host "[ok] $($_.Name)" + } + } + if ($failed) { exit 1 } + Write-Host "[ok] All PowerShell scripts parse cleanly." + + # Cache the Vulkan SDK installation directory so the ~200 MB download + # 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 + key: windows-vulkan-sdk-${{ hashFiles('scripts/install-vulkan-sdk.ps1') }} + + # Run install script (no-op on cache hit) then propagate the SDK path + # to subsequent steps via GITHUB_ENV. Without this, cargo/cmake can't + # find the Vulkan headers when the SDK was restored from cache rather + # than freshly installed (the machine-level env var isn't in the cache). + - name: Install Vulkan SDK + export VULKAN_SDK + shell: powershell + run: | + .\scripts\install-vulkan-sdk.ps1 + if (-not $env:VULKAN_SDK) { + Write-Error "VULKAN_SDK was not set by install-vulkan-sdk.ps1" + exit 1 + } + "VULKAN_SDK=$env:VULKAN_SDK" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "[ok] Exported VULKAN_SDK=$env:VULKAN_SDK" + + # ── Standalone LLVM 19 for bindgen ────────────────────────────────────── + # VS2022's bundled Clang 19 is an MSVC-compat build (clang-cl) whose + # mmintrin.h references __builtin_ia32_* GCC-style intrinsics that + # clang-cl doesn't implement, causing bindgen to panic + # with ~20 "use of undeclared identifier '__builtin_ia32_…'" errors. + # + # Fix: standalone LLVM 19 (non-MSVC-compat) implements all those builtins + # AND its headers match the same version VS2022 ships, so there is no + # version mismatch. KyleMayes/install-llvm-action downloads directly + # from the LLVM GitHub releases (no Chocolatey dependency), has its own + # built-in cache keyed on the version string, and installs to C:\LLVM + # (a path without spaces, which avoids quoting issues in env vars). + # + # clang-sys checks LIBCLANG_PATH before PATH, so the explicit export + # below guarantees bindgen finds standalone LLVM's libclang.dll even + # when ilammy/msvc-dev-cmd has already put VS's clang-cl on PATH. + - name: Install LLVM 19 + uses: KyleMayes/install-llvm-action@v2.0.9 + with: + version: "19" + directory: C:\LLVM + + - name: Export LIBCLANG_PATH + shell: powershell + run: | + # KyleMayes/install-llvm-action sets LLVM_PATH to the install root. + # libclang.dll lives in the bin/ subdirectory for Windows builds + # downloaded from LLVM GitHub releases. + $dll = Get-ChildItem "C:\LLVM" -Recurse -Filter "libclang.dll" ` + -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $dll) { + Write-Error "libclang.dll not found under C:\LLVM" + exit 1 + } + $dir = Split-Path $dll.FullName + "LIBCLANG_PATH=$dir" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "[ok] LIBCLANG_PATH=$dir (libclang.dll at $($dll.FullName))" + + # Also set LLVM_CONFIG to help with toolchain detection + "LLVM_CONFIG=C:\LLVM\bin\llvm-config.exe" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "[ok] LLVM_CONFIG=C:\LLVM\bin\llvm-config.exe" + + # clippy replaces check — clippy performs the same compilation plus lint + # passes, so running both wastes a full compile cycle. + - name: cargo clippy + shell: bash + env: + LIBCLANG_PATH: ${{ env.LIBCLANG_PATH }} + VULKAN_SDK: ${{ env.VULKAN_SDK }} + LLVM_CONFIG: ${{ env.LLVM_CONFIG }} + run: | + set -euo pipefail + run_cmd() { + cargo clippy -p skill \ + --target x86_64-pc-windows-msvc \ + -- -D warnings + } + run_cmd + + - name: Capture + summarize sccache hit rate (Windows job) + if: always() + shell: bash + run: | + mkdir -p ci-metrics + sccache --show-stats > ci-metrics/windows-sccache-stats.txt || true + STATS=ci-metrics/windows-sccache-stats.txt + if [[ ! -f "$STATS" ]]; then + echo "- sccache stats: unavailable" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + TOTAL_HIT_RATE="$(awk '/^Cache hits rate[[:space:]]/{print $(NF-1)" "$NF; exit}' "$STATS")" + RUST_HIT_RATE="$(awk '/^Cache hits rate \(Rust\)/{print $(NF-1)" "$NF; exit}' "$STATS")" + CACHE_LOCATION="$(awk -F'Cache location[[:space:]]+' '/^Cache location/{print $2; exit}' "$STATS")" + { + echo "## sccache metrics (Windows job)" + echo "- Cache location: ${CACHE_LOCATION:-unknown}" + echo "- Total hit rate: ${TOTAL_HIT_RATE:-unknown}" + echo "- Rust hit rate: ${RUST_HIT_RATE:-unknown}" + } >> "$GITHUB_STEP_SUMMARY" + + # ── LLM end-to-end integration test ────────────────────────────────────────── + llm-e2e: + name: LLM — E2E integration test + runs-on: ubuntu-latest + # Run after rust-check so we reuse its cached build artifacts (clippy + # already compiled the full workspace including skill-llm). This + # turns the E2E compilation into a cheap incremental build (~30 s) + # instead of a full rebuild (~3-5 min). + needs: rust-check + # Optional — only runs on manual dispatch with run_llm_e2e checked. + if: github.event_name == 'workflow_dispatch' && inputs.run_llm_e2e == true + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + + - name: Setup Rust bootstrap (Linux) + uses: ./.github/actions/setup-rust-bootstrap-linux + + # Reuse the same cache bucket as rust-check (default shared-key) + # so the E2E job picks up clippy's already-compiled artifacts. + # save-if: false — only rust-check writes to this cache. + - name: Cache Cargo registry + build artifacts + uses: Swatinem/rust-cache@v2.9.1 + with: + workspaces: ". -> src-tauri/target" + shared-key: linux-ci-x86_64-unknown-linux-gnu-v1 + save-if: false + cache-targets: false + cache-on-failure: false + + - name: Setup Vulkan SDK repository + uses: ./.github/actions/setup-vulkan-linux + + # Install all system deps in one cached step (includes mold + clang + # to match rust-check's linker and reuse its build artifacts). + - name: Cache + install system deps + uses: awalsh128/cache-apt-pkgs-action@v1 + with: + packages: >- + libssl-dev pkg-config cmake binutils + libespeak-ng-dev libasound2-dev + libpipewire-0.3-dev mold clang + protobuf-compiler libopenblas-dev + vulkan-sdk + version: llm-e2e-v6 + + - name: Cache HuggingFace model + uses: actions/cache@v5 + with: + path: ~/.cache/huggingface + key: hf-llm-e2e-v1 + + - name: Run LLM E2E test + env: + RUSTC_WRAPPER: sccache + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=mold + # Cache cmake-based -sys crate builds (espeak-ng-sys, etc.) + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache + run: | + cargo test -p skill-llm --locked --target x86_64-unknown-linux-gnu \ + --features llm \ + --test llm_e2e -- --nocapture 2>&1 | tee llm-e2e-report.txt + # Propagate the test exit code (tee masks it). + exit ${PIPESTATUS[0]} + + - name: Write Job Summary + if: always() + run: | + FILE=llm-e2e-report.txt + [ -f "$FILE" ] || { echo "No report file found" >> "$GITHUB_STEP_SUMMARY"; exit 0; } + + echo "## LLM E2E Integration Test" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + # Extract model info + MODEL=$(grep -oP '(?<=Model: )\S+' "$FILE" | head -1 || true) + TOTAL=$(grep 'TOTAL' "$FILE" | grep -oP '[0-9]+\.[0-9]+s' | head -1 || true) + if [ -n "$MODEL" ]; then + echo "**Model:** \`$MODEL\` **Total:** \`$TOTAL\`" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + fi + + # Extract the report box (between the two ╔ headers) and format as + # a fenced code block — preserves the box-drawing layout perfectly. + echo "### Report" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + # Grab everything from "E2E INTEGRATION TEST REPORT" to the closing ╚ + sed -n '/E2E INTEGRATION TEST REPORT/,/^╚/p' "$FILE" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + # Verdict + if grep -q 'ALL CHECKS PASSED' "$FILE"; then + echo "### Result: ALL CHECKS PASSED ✅" >> "$GITHUB_STEP_SUMMARY" + else + echo "### Result: SOME CHECKS FAILED ❌" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload E2E report + if: always() + uses: actions/upload-artifact@v7 + with: + name: llm-e2e-report + path: llm-e2e-report.txt + retention-days: 30 + + # ── LSL end-to-end integration test ─────────────────────────────────────────── + lsl-e2e: + name: LSL — E2E integration test + runs-on: ubuntu-latest + needs: rust-check + # Optional — only runs on manual dispatch with run_lsl_e2e checked. + if: github.event_name == 'workflow_dispatch' && inputs.run_lsl_e2e == true + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx + + - name: Setup Rust bootstrap (Linux) + uses: ./.github/actions/setup-rust-bootstrap-linux + + # Reuse rust-check's cached artifacts (save-if: false — only rust-check writes). + - name: Cache Cargo registry + build artifacts + uses: Swatinem/rust-cache@v2.9.1 + with: + workspaces: ". -> src-tauri/target" + shared-key: linux-ci-x86_64-unknown-linux-gnu-v1 + save-if: false + cache-targets: false + cache-on-failure: false + + # rlsl is pure Rust (no liblsl system dependency) so only the minimal + # set of packages is needed: mold+clang for the fast linker, and the + # basic dev headers shared with the workspace build. + - name: Cache + install system deps + uses: awalsh128/cache-apt-pkgs-action@v1 + with: + packages: >- + libssl-dev pkg-config cmake binutils + libespeak-ng-dev libasound2-dev + libpipewire-0.3-dev mold clang + protobuf-compiler libopenblas-dev + version: lsl-e2e-v2 + + - name: Run LSL E2E test + env: + RUSTC_WRAPPER: sccache + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=mold + run: | + cargo test -p skill-lsl --locked --target x86_64-unknown-linux-gnu \ + --test lsl_e2e -- --nocapture 2>&1 | tee lsl-e2e-report.txt + exit ${PIPESTATUS[0]} + + - name: Write Job Summary + if: always() + run: | + FILE=lsl-e2e-report.txt + [ -f "$FILE" ] || { echo "No report file found" >> "$GITHUB_STEP_SUMMARY"; exit 0; } + + echo "## LSL E2E Integration Test" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + echo '### Report' >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + sed -n '/LSL E2E INTEGRATION TEST REPORT/,/^╔/{ /^╔/{ p; d }; p }' "$FILE" \ + || sed -n '/LSL E2E INTEGRATION TEST REPORT/,/^╚/p' "$FILE" \ + >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if grep -q 'ALL STEPS PASSED' "$FILE"; then + echo "### Result: ALL STEPS PASSED ✅" >> "$GITHUB_STEP_SUMMARY" + else + echo "### Result: SOME STEPS FAILED ❌" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload E2E report + if: always() + uses: actions/upload-artifact@v7 + with: + name: lsl-e2e-report + path: lsl-e2e-report.txt + retention-days: 30 + + # ── Rust code coverage ──────────────────────────────────────────────────────── + coverage: + name: Rust — code coverage + runs-on: ubuntu-latest + needs: rust-check + timeout-minutes: 90 + if: github.event_name == 'workflow_dispatch' && inputs.run_coverage == true + env: + RUSTC_WRAPPER: "" + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - 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 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo registry + build artifacts + uses: Swatinem/rust-cache@v2.9.1 + with: + workspaces: ". -> src-tauri/target" + shared-key: linux-ci-x86_64-unknown-linux-gnu-v1 + save-if: false + cache-targets: false + cache-on-failure: false + + - name: Setup Vulkan SDK repository + uses: ./.github/actions/setup-vulkan-linux + + - name: Cache + install Tauri system deps (incl. mold + clang + Vulkan) + uses: awalsh128/cache-apt-pkgs-action@v1 + with: + packages: >- + libwebkit2gtk-4.1-dev libappindicator3-dev + librsvg2-dev patchelf libssl-dev + libudev-dev libdbus-1-dev pkg-config + libespeak-ng-dev libasound2-dev + libpipewire-0.3-dev libgbm-dev + cmake binutils mold clang + protobuf-compiler libopenblas-dev + vulkan-sdk + version: tauri-rust-check-v7 + + - name: Install cargo-llvm-cov + run: cargo install cargo-llvm-cov --locked + + - name: Generate coverage (lcov + threshold) + env: + COVERAGE_MIN_LINES: ${{ inputs.coverage_min_lines || '55' }} + run: | + cargo llvm-cov clean --workspace + cargo llvm-cov \ + --workspace \ + --exclude iroh_test_client \ + --exclude iroh-example-client \ + --exclude skill \ + --no-default-features \ + --features llm-rlx-cpu,skill-router/cpu,skill-daemon/cpu-only \ + --lcov \ + --output-path lcov.info + + cargo llvm-cov \ + --workspace \ + --exclude iroh_test_client \ + --exclude iroh-example-client \ + --exclude skill \ + --no-default-features \ + --features llm-rlx-cpu,skill-router/cpu,skill-daemon/cpu-only \ + --summary-only \ + --fail-under-lines ${COVERAGE_MIN_LINES} + + - name: Upload lcov artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: rust-lcov + path: lcov.info + retention-days: 30 # ── Security audit ──────────────────────────────────────────────────────────── + # + # Merged the previous separate cargo-audit + audit jobs into one. + # Runs only on main/develop pushes and manual triggers — advisory only, + # not worth blocking every PR. audit: name: Security audit runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + permissions: + contents: read + env: + RUSTC_WRAPPER: "" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Checkout RLX + uses: ./.github/actions/checkout-rlx - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' + - name: Update npm to latest + run: npm install -g npm@latest + - name: npm audit run: npm audit --omit=dev --audit-level=high continue-on-error: true - - name: Install Rust stable + - name: Setup Rust uses: dtolnay/rust-toolchain@stable - - name: Cache Cargo (audit) - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - ~/.cargo/bin/cargo-audit - # cargo-audit's own deps change with Cargo.lock; bin is also covered. - key: linux-cargo-audit-${{ hashFiles('src-tauri/Cargo.lock') }} - restore-keys: linux-cargo-audit- + - name: Install cargo-audit + run: cargo install --locked cargo-audit - name: cargo audit - working-directory: src-tauri + run: cargo audit --deny warnings + + - name: Install cargo-deny + run: cargo install --locked cargo-deny + + - name: cargo deny + run: cargo deny check -A no-license-field -A parse-error -A license-not-encountered + + # ── Discord notification ────────────────────────────────────────────────── + notify: + name: Discord notification + runs-on: ubuntu-latest + needs: [rust-check, windows-check, frontend-check, audit, llm-e2e, lsl-e2e, coverage] + if: always() + # Secret is empty for Dependabot PRs (separate secret store) and forks. + # Hoist to job-level env so the step's `if` can gate on it. + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + - name: Send CI result to Discord + if: env.DISCORD_WEBHOOK_URL != '' + env: + # Take the real commit message from the event payload. On + # pull_request the checked-out HEAD is an auto-generated merge + # commit, so `git log` would show "Merge into " instead + # of the actual change. Passed via env to avoid script injection. + EVENT_COMMIT_MSG: ${{ github.event.head_commit.message || github.event.pull_request.title }} run: | - cargo install cargo-audit --locked 2>/dev/null || true - cargo audit - continue-on-error: true + COMMIT_MSG="$EVENT_COMMIT_MSG" + if [ -z "$COMMIT_MSG" ]; then + COMMIT_MSG=$(git log -1 --format='%B') + fi + # Cap length for the Discord embed field (limit 1024) and add an + # ellipsis so it's clear when there's more. Substring is char-based + # so it never splits a multi-byte UTF-8 sequence. + if [ "${#COMMIT_MSG}" -gt 500 ]; then + COMMIT_MSG="${COMMIT_MSG:0:500}…" + fi + RUST="${{ needs.rust-check.result }}" + WINDOWS="${{ needs.windows-check.result }}" + FRONTEND="${{ needs.frontend-check.result }}" + AUDIT="${{ needs.audit.result }}" + LLM_E2E="${{ needs.llm-e2e.result }}" + LSL_E2E="${{ needs.lsl-e2e.result }}" + COVERAGE="${{ needs.coverage.result }}" + + # Determine overall status — Rust (Linux), Windows, and Frontend are + # required gates. Audit and LLM E2E are advisory. + if [[ "$RUST" == "success" && "$WINDOWS" == "success" && "$FRONTEND" == "success" ]]; then + COLOR=3066993 # green + STATUS="✅ Passed" + else + COLOR=15158332 # red + STATUS="❌ Failed" + fi + + EMOJI_RUST=$([ "$RUST" = "success" ] && echo "✅" || echo "❌") + EMOJI_WINDOWS=$([ "$WINDOWS" = "success" ] && echo "✅" || echo "❌") + EMOJI_FRONTEND=$([ "$FRONTEND" = "success" ] && echo "✅" || echo "❌") + EMOJI_AUDIT=$([ "$AUDIT" = "success" ] && echo "✅" || ([ "$AUDIT" = "skipped" ] && echo "⏭️" || echo "⚠️")) + EMOJI_LLM=$([ "$LLM_E2E" = "success" ] && echo "✅" || ([ "$LLM_E2E" = "skipped" ] && echo "⏭️" || echo "⚠️")) + EMOJI_LSL=$([ "$LSL_E2E" = "success" ] && echo "✅" || ([ "$LSL_E2E" = "skipped" ] && echo "⏭️" || echo "⚠️")) + EMOJI_COV=$([ "$COVERAGE" = "success" ] && echo "✅" || ([ "$COVERAGE" = "skipped" ] && echo "⏭️" || echo "⚠️")) + + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + REF="${{ github.ref_name }}" + # On pull_request github.sha is the merge commit; prefer the PR's + # real head commit so the SHA and its link point at the change. + SHA="${{ github.event.pull_request.head.sha || github.sha }}" + COMMIT_URL="${{ github.server_url }}/${{ github.repository }}/commit/$SHA" + ACTOR="${{ github.actor }}" + EVENT="${{ github.event_name }}" + + curl -s -X POST "$DISCORD_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{ + \"embeds\": [{ + \"title\": \"CI — $STATUS\", + \"url\": \"$RUN_URL\", + \"color\": $COLOR, + \"fields\": [ + {\"name\": \"Branch\", \"value\": \"\`$REF\`\", \"inline\": true}, + {\"name\": \"Commit\", \"value\": \"[\`${SHA:0:8}\`]($COMMIT_URL)\", \"inline\": true}, + {\"name\": \"Triggered\", \"value\": \"$EVENT by $ACTOR\", \"inline\": true}, + {\"name\": \"Commit Message\", \"value\": $(echo "$COMMIT_MSG" | jq -Rs .), \"inline\": false}, + {\"name\": \"Rust (Linux+Vulkan)\", \"value\": \"$EMOJI_RUST $RUST\", \"inline\": true}, + {\"name\": \"Rust (Windows)\", \"value\": \"$EMOJI_WINDOWS $WINDOWS\", \"inline\": true}, + {\"name\": \"Frontend\", \"value\": \"$EMOJI_FRONTEND $FRONTEND\", \"inline\": true}, + {\"name\": \"Audit\", \"value\": \"$EMOJI_AUDIT $AUDIT\", \"inline\": true}, + {\"name\": \"LLM E2E\", \"value\": \"$EMOJI_LLM $LLM_E2E\", \"inline\": true}, + {\"name\": \"LSL E2E\", \"value\": \"$EMOJI_LSL $LSL_E2E\", \"inline\": true}, + {\"name\": \"Coverage\", \"value\": \"$EMOJI_COV $COVERAGE\", \"inline\": true} + ], + \"footer\": {\"text\": \"${{ github.repository }}\"} + }] + }" diff --git a/.github/workflows/homebrew-cask.yml b/.github/workflows/homebrew-cask.yml new file mode 100644 index 000000000..e5301ae2e --- /dev/null +++ b/.github/workflows/homebrew-cask.yml @@ -0,0 +1,87 @@ +name: Homebrew cask + +# Keeps Casks/neuroskill.rb pinned to the latest *stable* GitHub release. +# +# Why a reconcile (not a step inside Release - Mac): a release published with the +# default GITHUB_TOKEN cannot, by design, trigger another workflow, so a +# `release: [released]` trigger would never fire. Instead this job runs after the +# mac release build completes (and on a weekly safety net), reads the current +# latest stable release, and opens a PR whenever the cask has drifted. RC builds +# are harmless: the reconcile always targets the latest *stable*, so an RC run +# finds no drift and does nothing. +on: + workflow_run: + workflows: ["Release - Mac"] + types: [completed] + schedule: + - cron: "17 6 * * 1" # Mondays 06:17 UTC + workflow_dispatch: + inputs: + version: + description: "Version to pin (e.g. 0.0.130); defaults to the latest stable release" + required: false + +permissions: + contents: write # push the reconcile branch + pull-requests: write # open the bump PR + +concurrency: + group: homebrew-cask + cancel-in-progress: false + +jobs: + reconcile: + name: Reconcile Homebrew cask + runs-on: ubuntu-latest + # For workflow_run, only proceed when the upstream build actually succeeded. + if: >- + github.event_name != 'workflow_run' || + github.event.workflow_run.conclusion == 'success' + steps: + - name: Checkout default branch + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Reconcile cask to latest stable release + env: + GH_TOKEN: ${{ github.token }} + run: | + ARGS="" + if [ -n "${{ github.event.inputs.version }}" ]; then + ARGS="--version ${{ github.event.inputs.version }}" + fi + node scripts/ci.mjs update-cask $ARGS + + - name: Open PR if the cask drifted + env: + GH_TOKEN: ${{ github.token }} + run: | + if git diff --quiet -- Casks/neuroskill.rb; then + echo "Cask already current — nothing to do." + exit 0 + fi + + VERSION="$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' Casks/neuroskill.rb | head -1)" + BRANCH="automated/homebrew-cask" + TITLE="chore(homebrew): bump cask to v${VERSION}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add Casks/neuroskill.rb + git commit -m "$TITLE" + git push --force origin "$BRANCH" + + if gh pr view "$BRANCH" --json state --jq .state 2>/dev/null | grep -q OPEN; then + echo "PR already open for $BRANCH — branch updated in place." + else + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "$TITLE" \ + --body "Automated reconcile of \`Casks/neuroskill.rb\` to the latest stable release (v${VERSION}). Generated by \`.github/workflows/homebrew-cask.yml\`." + fi diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 0b20a6053..17fed93fa 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -60,6 +60,12 @@ permissions: contents: read # checkout pull-requests: write # post download-link comment +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + # WidgetKit signing currently breaks Apple notarization (Invalid). Set to + # 'true' to re-enable once appex signing/timestamps are fixed. + ENABLE_WIDGETKIT: 'false' + jobs: preview: # ── [FIX 1] Fork rejection ──────────────────────────────────────────────── @@ -94,7 +100,7 @@ jobs: # maintainer. - name: Verify labeler has write access if: github.event_name == 'pull_request' - uses: actions/github-script@60a0d83039f74a4aee543508d474c4c82a9f1b84 # v7.0.1 + uses: actions/github-script@v9 with: script: | const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ @@ -129,24 +135,10 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - run: | - ok=true - for var in \ - APPLE_CERTIFICATE \ - APPLE_CERTIFICATE_PASSWORD \ - APPLE_SIGNING_IDENTITY \ - APPLE_ID \ - APPLE_PASSWORD \ - APPLE_TEAM_ID \ - TAURI_SIGNING_PRIVATE_KEY - do - if [ -z "${!var}" ]; then - echo "::error::Secret '$var' is empty or not set in the Release environment." - ok=false - fi - done - $ok || exit 1 - echo "✓ All signing and notarization secrets are present." + run: >- + node scripts/ci.mjs verify-secrets + APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY + APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY # ── Resolve what to check out ─────────────────────────────────────────── # @@ -170,11 +162,14 @@ jobs: # ── Checkout ──────────────────────────────────────────────────────────── # Reached only after all three security gates above have passed. - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@v6 with: 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 @@ -211,40 +206,53 @@ jobs: # ── Rust toolchain ────────────────────────────────────────────────────── - name: Install Rust stable - uses: dtolnay/rust-toolchain@4305c38b25c5dd5af9c4cfe7dd25e3c5c7ba2e58 # stable (2025-02) + uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin - name: Cache Cargo registry + build artifacts - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34b5c520640 # v2.7.8 + uses: Swatinem/rust-cache@v2.9.1 with: - workspaces: src-tauri + workspaces: ". -> src-tauri/target" + + # ── Build cache (sccache) ───────────────────────────────────────────────── + - name: Setup sccache + uses: Mozilla-Actions/sccache-action@v0.0.9 + + - name: Configure sccache for GitHub Actions cache + shell: bash + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=10G" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=1" >> "$GITHUB_ENV" # ── Node.js ───────────────────────────────────────────────────────────── - name: Setup Node.js - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' + - name: Update npm to latest + run: npm install -g npm@latest + - name: Install JS dependencies run: npm ci - # ── ImageMagick (needed by scripts/stamp-dmg-icon.sh) ──────────────────── - - name: Install ImageMagick - run: brew install imagemagick + - name: Install protoc + GNU ar (macOS) + run: | + brew install protobuf binutils + # Use GNU ar to suppress "illegal option -- D" warnings from Apple's system ar + echo "AR=$(brew --prefix binutils)/bin/gar" >> "$GITHUB_ENV" - # ── espeak-ng static library ───────────────────────────────────────────── - - name: Cache espeak-ng static lib - id: cache-espeak - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c6158d # v4.2.0 - with: - path: src-tauri/espeak-static - key: macos-arm64-espeak-${{ hashFiles('scripts/build-espeak-static.sh') }} + - name: i18n sync check + run: npm run -s sync:i18n:check - - name: Build espeak-ng static lib - if: steps.cache-espeak.outputs.cache-hit != 'true' - run: ESPEAK_ARCHS=arm64 bash scripts/build-espeak-static.sh + - name: i18n audit check + run: npm run -s audit:i18n:check + + - name: i18n locales check (all non-en locales) + run: npm run -s check:i18n:locales # ── Validate Apple notarization credentials ────────────────────────────── - name: Validate Apple notarization credentials @@ -252,68 +260,99 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - echo "Checking notarization credentials…" - if ! xcrun notarytool history \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --output-format json 2>&1 | grep -q '"history"'; then - echo "::error::Apple notarization credentials are invalid." - echo "::error::Generate a fresh app-specific password at:" - echo "::error:: https://appleid.apple.com → App-Specific Passwords" - echo "::error::Then update APPLE_PASSWORD in: Settings → Environments → Release" - exit 1 - fi - echo "✓ Notarization credentials are valid." + run: node scripts/ci.mjs validate-notarization # ── Import Apple Developer certificate into a temporary keychain ───────── - name: Import Apple Developer certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: node scripts/ci.mjs import-apple-cert + + # ── WidgetKit extension (optional; off until notarization-safe) ───────── + - name: Install XcodeGen + if: env.ENABLE_WIDGETKIT == 'true' + run: brew install xcodegen + + - name: Setup WidgetKit signing + if: env.ENABLE_WIDGETKIT == 'true' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_ASC_KEY_ID: ${{ secrets.APPLE_ASC_KEY_ID }} + APPLE_ASC_KEY_ISSUER_ID: ${{ secrets.APPLE_ASC_KEY_ISSUER_ID }} + APPLE_ASC_KEY_BASE64: ${{ secrets.APPLE_ASC_KEY_BASE64 }} + APPLE_WIDGET_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_WIDGET_PROVISIONING_PROFILE_BASE64 }} + run: node scripts/ci.mjs setup-widget-signing + + - name: Build WidgetKit extension + if: env.ENABLE_WIDGETKIT == 'true' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + AUTH_KEY_PATH: ${{ env.AUTH_KEY_PATH }} + AUTH_KEY_ID: ${{ env.AUTH_KEY_ID }} + AUTH_KEY_ISSUER_ID: ${{ env.AUTH_KEY_ISSUER_ID }} + run: node scripts/ci.mjs build-widgets + + # ── Compile: frontend + Rust binary ────────────────────────────────────── + # Identical rationale to release-mac.yml — see that file for full details. + # --features custom-protocol tells Tauri to embed the SvelteKit build + # output and serve it via the custom:// protocol instead of connecting + # to the dev server (localhost:1420). + - name: Compile (frontend + Rust) + env: + RUSTC_WRAPPER: sccache + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache run: | - KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" - KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" - echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" - echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV" - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - echo -n "$APPLE_CERTIFICATE" \ - | base64 --decode -o "$RUNNER_TEMP/cert.p12" - security import "$RUNNER_TEMP/cert.p12" \ - -k "$KEYCHAIN_PATH" \ - -P "$APPLE_CERTIFICATE_PASSWORD" \ - -T /usr/bin/codesign \ - -T /usr/bin/security - rm -f "$RUNNER_TEMP/cert.p12" - - security set-key-partition-list \ - -S apple-tool:,apple: \ - -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security list-keychains -d user \ - -s "$KEYCHAIN_PATH" login.keychain - - # ── Tauri build — sign + notarize ──────────────────────────────────────── + npm run build + cargo build -p skill --release --locked --target aarch64-apple-darwin --features custom-protocol + + # ── Assemble .app bundle ────────────────────────────────────────────── + - name: Create .app bundle + env: + SKIP_WIDGETS: ${{ env.ENABLE_WIDGETKIT == 'true' && '0' || '1' }} + run: bash scripts/assemble-macos-app.sh aarch64-apple-darwin + + # ── Sign .app + create DMG (background, README, CHANGELOG, LICENSE) ───── # - # Identical to the official release build. The only difference is that no - # GitHub Release is created and the artifact name includes the SHA. - - name: Build Tauri app (sign + notarize) + # Uses sindresorhus/create-dmg via scripts/create-macos-dmg.sh which + # signs .app, creates DMG with composed icon + Retina background + SLA, + # signs the DMG, and notarizes with Apple. + - name: Install DMG dependencies + run: | + # npm@latest no longer runs dependency install scripts by default, so + # appdmg's native deps (fs-xattr, macos-alias) skip their node-gyp build + # and `volume.node` goes missing. Allowlist their scripts explicitly. + npm install --global appdmg --allow-scripts=fs-xattr,macos-alias + pip3 install --quiet Pillow + + - name: Sign .app + Create DMG + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: bash scripts/create-macos-dmg.sh aarch64-apple-darwin + + # ── Create updater artifact from the signed + stapled .app ───────────── + # Runs after DMG so the .app is already notarized and stapled. + - name: Recreate updater artifact from signed .app env: - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - ESPEAK_LIB_DIR: ${{ github.workspace }}/src-tauri/espeak-static/lib run: | - npx tauri build \ - --target aarch64-apple-darwin \ - --bundles app,dmg + BUNDLE_DIR="src-tauri/target/aarch64-apple-darwin/release/bundle" + APP_BUNDLE="$(find "$BUNDLE_DIR/macos" -name "*.app" -maxdepth 1 | head -1)" + + find "$BUNDLE_DIR/macos" \ + \( -name "*.app.tar.gz" -o -name "*.app.tar.gz.sig" \) -delete + + TAR_PATH="$BUNDLE_DIR/macos/$(basename "$APP_BUNDLE").tar.gz" + tar -czf "$TAR_PATH" \ + -C "$(dirname "$APP_BUNDLE")" "$(basename "$APP_BUNDLE")" + + npx tauri signer sign -f "$TAR_PATH" # ── Always delete the temporary keychain ───────────────────────────────── - name: Delete temporary keychain @@ -353,13 +392,10 @@ jobs: echo " TAR : $APP_TAR ($(du -sh "$APP_TAR" | cut -f1))" echo " SIG : $APP_SIG" - # ── Stamp version badge onto the DMG icon ──────────────────────────────── - - name: Stamp version badge onto DMG icon - run: | - bash scripts/stamp-dmg-icon.sh \ - "${{ steps.artifacts.outputs.dmg }}" \ - "${{ env.VERSION }}" \ - "src-tauri/icons/icon.png" + - name: Prepare changelog notes for preview artifact + run: >- + node scripts/ci.mjs prepare-changelog + "${{ steps.label.outputs.conf_version }}" preview-notes.md # ── Upload to GitHub Actions artifacts (14-day retention) ──────────────── # @@ -381,6 +417,62 @@ jobs: ${{ steps.artifacts.outputs.dmg }} ${{ steps.artifacts.outputs.app_tar }} ${{ steps.artifacts.outputs.app_sig }} + preview-notes.md + + # ── Notify Discord of preview build result ─────────────────────────────── + - name: Notify Discord of preview build + if: always() + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + JOB_STATUS: ${{ job.status }} + run: | + # Full commit message (subject + body); HEAD here is the PR's real + # head sha (checked out with fetch-depth: 0), not a merge commit. + COMMIT_MSG=$(git log -1 --format='%B') + if [ "${#COMMIT_MSG}" -gt 500 ]; then + COMMIT_MSG="${COMMIT_MSG:0:500}…" + fi + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + SHA="${{ steps.label.outputs.sha }}" + VERSION="${{ steps.label.outputs.conf_version }}" + TITLE="${{ steps.label.outputs.build_title }}" + ARTIFACT="${{ steps.label.outputs.artifact_name }}" + PR="${{ steps.meta.outputs.pr }}" + BRANCH="${{ steps.meta.outputs.branch }}" + + if [[ "$JOB_STATUS" == "success" ]]; then + COLOR=3066993 # green + EMOJI="✅" + DESC="Preview build succeeded. Artifact available for 14 days." + else + COLOR=15158332 # red + EMOJI="❌" + DESC="Preview build failed. Check the run for details." + fi + + PR_FIELD="" + if [[ -n "$PR" ]]; then + PR_URL="${{ github.server_url }}/${{ github.repository }}/pull/$PR" + PR_FIELD="{\"name\": \"PR\", \"value\": \"[#$PR]($PR_URL)\", \"inline\": true}," + fi + + curl -s -X POST "$DISCORD_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{ + \"embeds\": [{ + \"title\": \"$EMOJI Preview Build — \`v$VERSION\` @ \`$SHA\`\", + \"description\": \"$DESC\n\n**[View run & download artifact]($RUN_URL)**\", + \"url\": \"$RUN_URL\", + \"color\": $COLOR, + \"fields\": [ + $PR_FIELD + {\"name\": \"Branch\", \"value\": \"\`$BRANCH\`\", \"inline\": true}, + {\"name\": \"Artifact\", \"value\": \"\`$ARTIFACT\`\", \"inline\": true}, + {\"name\": \"Commit Message\", \"value\": $(echo "$COMMIT_MSG" | jq -Rs .), \"inline\": false} + ], + \"footer\": {\"text\": \"${{ github.repository }}\"} + }] + }" # ── Post (or update) a download-link comment on the PR ─────────────────── # @@ -389,7 +481,7 @@ jobs: # rather than creating a new one — keeps the PR thread tidy. - name: Comment on PR with download link if: steps.meta.outputs.pr != '' - uses: actions/github-script@60a0d83039f74a4aee543508d474c4c82a9f1b84 # v7.0.1 + uses: actions/github-script@v9 with: script: | const pr = Number('${{ steps.meta.outputs.pr }}'); diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 000000000..66bb70e64 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,74 @@ +name: Release PR checks + +# Soft-enforces the merge strategy required by the RC pipeline. +# +# The promote workflow keys off the *head commit's message* on main matching +# the version string (`0.5.1-rc.3`). Rebase merge and squash merge both +# preserve that message; a regular merge commit doesn't, so promote would +# silently skip and the stable channel would stay pinned to the previous +# release. +# +# This workflow doesn't block merging — repo-level merge-strategy settings +# can do that, and GitHub's merge dropdown is the right place to enforce +# allowed methods. What this provides is an unmissable inline reminder on +# every PR labeled `release`, so the person clicking "Merge" sees the +# constraint right next to the button. + +on: + pull_request: + types: [labeled, opened, reopened, synchronize] + +permissions: + pull-requests: write + contents: read + +jobs: + remind-merge-strategy: + name: Remind merger to use rebase or squash + runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'release') + steps: + - name: Upsert merge-strategy reminder comment + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # Marker keeps this idempotent: we either find and update the + # existing reminder, or post one new one. Multiple labels/syncs + # don't pile up. + MARKER='' + + BODY=$(cat <<'EOF' + + ### Merge strategy: **rebase** or **squash** only + + This PR is labeled `release`. Promotion of the most recent RC to stable depends on the merged commit message on `main` matching the version string exactly (e.g. `0.5.1-rc.3`). + + A regular merge commit would: + - create a different commit hash than the tested RC → **break bit-identity** between RC and stable + - carry a generic merge message → **the promote workflow silently skips**, leaving stable pointing at the previous release + + When merging, click the dropdown next to the green button and pick **Rebase and merge** or **Squash and merge**. + EOF + ) + + # Find existing comment by marker. `gh pr view --json comments` + # returns all comments; we filter client-side for the marker. + EXISTING_ID=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json comments \ + --jq ".comments[] | select(.body | contains(\"$MARKER\")) | .id" \ + | head -1 || true) + + if [ -n "$EXISTING_ID" ]; then + # `gh pr comment` doesn't have an --edit mode; use the API directly. + # The comment ID from `gh pr view` is a node ID; the REST API needs + # the numeric comments ID. Fall back to creating a new comment if + # editing fails — the marker prevents duplication on next sync. + echo "Existing reminder found ($EXISTING_ID); leaving in place." + else + echo "$BODY" | gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file - + echo "Posted merge-strategy reminder on PR #$PR_NUMBER." + fi diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 000000000..cc0ef4b90 --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,73 @@ +name: Promote RC to stable + +# Fired when a release PR is merged to main using rebase or squash merge. +# bump.js commits with the version string as the message (e.g. "0.5.1-rc.3"), +# and rebase/squash preserves that commit hash + message on main. We detect it +# here and promote the matching RC release: clear the prerelease flag and mark +# it as latest. The promoted artifacts are byte-identical to the tested RC — +# no rebuild, no re-notarization. GitHub's `releases/latest` URL semantics do +# the rest, and stable channel users see the new version on their next poll. +# +# A regular merge commit creates a different head SHA whose message doesn't +# match the version regex, so promote silently skips. The companion workflow +# (pr-checks.yml) flags release PRs that would create such a merge commit. + +on: + push: + branches: [main] + +permissions: + contents: write # needed to edit release metadata + +jobs: + promote: + name: Promote matching RC tag + runs-on: ubuntu-latest + # Cheap pre-filter: head commit message must contain `-rc.` somewhere. + # The strict regex check happens in the step below. + if: contains(github.event.head_commit.message, '-rc.') + steps: + - uses: actions/checkout@v4 + + - name: Identify the RC to promote + id: rc + env: + HEAD_MSG: ${{ github.event.head_commit.message }} + run: | + set -euo pipefail + + # bump.js commits exactly the version string. We accept both the bare + # message and an optional leading "v" out of paranoia; nothing else. + if [[ "$HEAD_MSG" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + echo "should_promote=true" >> "$GITHUB_OUTPUT" + echo "::notice::Detected RC promotion target: v$VERSION" + else + echo "should_promote=false" >> "$GITHUB_OUTPUT" + echo "::notice::Head commit message is not a release-bump commit, skipping promotion." + echo " message: $HEAD_MSG" + fi + + - name: Verify the release exists + if: steps.rc.outputs.should_promote == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if ! gh release view "${{ steps.rc.outputs.tag }}" >/dev/null 2>&1; then + echo "::error::Release ${{ steps.rc.outputs.tag }} does not exist. Did the build workflow finish?" + exit 1 + fi + + - name: Clear prerelease flag and mark as latest + if: steps.rc.outputs.should_promote == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release edit "${{ steps.rc.outputs.tag }}" \ + --prerelease=false \ + --latest + echo "::notice::Promoted ${{ steps.rc.outputs.tag }} to stable. The releases/latest/download/latest.json URL now resolves here." diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 000000000..845f501e2 --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -0,0 +1,431 @@ +name: Release — Linux + +# Triggered by the same semver tag as the macOS and Windows release workflows. +# All three run in parallel; each uploads its own binaries and then merges +# its platform entry into the shared latest.json updater manifest. +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+' + workflow_dispatch: + +permissions: + contents: write # create/update GitHub Releases and upload assets + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + release-linux: + name: Build & Release — x86_64-unknown-linux-gnu + runs-on: ubuntu-24.04 + environment: Release + + steps: + # ── Free pre-installed software we don't use (~10 GB reclaimed) ────────── + - name: Free disk space + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ + /usr/local/share/powershell /usr/local/share/chromium /usr/share/swift \ + /opt/hostedtoolcache/CodeQL + sudo docker image prune -af 2>/dev/null || true + df -h / + + # ── Checkout ────────────────────────────────────────────────────────────── + - name: Checkout + uses: actions/checkout@v6 + 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 + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_REF: ${{ github.ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: node scripts/ci.mjs resolve-version + + # ── Vulkan SDK repo ───────────────────────────────────────────────────── + # Add the LunarG apt repository BEFORE the cached apt install step so + # that vulkan-sdk is resolvable by apt in the cache-apt-pkgs-action. + - name: Setup Vulkan SDK repository + uses: ./.github/actions/setup-vulkan-linux + + # ── System dependencies ─────────────────────────────────────────────────── + # Linux compile + system package flow requires WebKit2GTK and supporting + # libraries for tauri/wry compilation. vulkan-sdk is included here so + # all apt packages are cached in a single cache-apt-pkgs-action call. + - name: Cache + install system dependencies (incl. mold + clang + Vulkan) + uses: awalsh128/cache-apt-pkgs-action@v1 + with: + packages: >- + libwebkit2gtk-4.1-dev + libappindicator3-dev + librsvg2-dev + patchelf + libssl-dev + libudev-dev + libdbus-1-dev + pkg-config + libasound2-dev + libpipewire-0.3-dev + libgbm-dev + cmake + binutils + rpm + mold + clang + protobuf-compiler + libopenblas-dev + vulkan-sdk + version: tauri-release-linux-v7 + + # ── Rust toolchain + build cache bootstrap ─────────────────────────────── + - name: Setup Rust bootstrap (Linux) + uses: ./.github/actions/setup-rust-bootstrap-linux + with: + targets: x86_64-unknown-linux-gnu + + # ── Cargo cache ─────────────────────────────────────────────────────────── + - name: Cache Cargo registry + build artifacts + uses: Swatinem/rust-cache@v2.9.1 + with: + workspaces: ". -> src-tauri/target" + shared-key: linux-release-x86_64-unknown-linux-gnu-v1 + cache-targets: false + cache-on-failure: false + + # ── ONNX Runtime binary cache ────────────────────────────────────────── + # ort-sys downloads ~200 MB static libs from cdn.pyke.io during build. + # Cache them so the download only happens once (avoids CDN flakiness). + - name: Cache ONNX Runtime binaries (ort-sys) + uses: actions/cache@v5 + with: + path: ~/.cache/ort.pyke.io + key: linux-ort-${{ hashFiles('Cargo.lock') }} + restore-keys: linux-ort- + + + # ── Node.js ─────────────────────────────────────────────────────────────── + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Update npm to latest + run: npm install -g npm@latest + + - name: Install JS dependencies + run: npm ci + + # ── Pre-flight: verify every required secret is set ────────────────────── + # Linux builds don't need platform code-signing credentials (there is no + # Linux equivalent of Apple notarization or Windows Authenticode), but the + # shared Tauri Ed25519 updater signing key is still required. + - name: Verify signing secrets are present + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: node scripts/ci.mjs verify-secrets TAURI_SIGNING_PRIVATE_KEY + + # ── espeak-ng static library ────────────────────────────────────────────── + # ── Build frontend ──────────────────────────────────────────────────────── + - name: Build frontend + run: npm run build + + - name: Verify Tauri frontend bundle structure + run: npm run -s verify:tauri:frontend + + # ── Clean cached outputs that can mask stale release artifacts ────────── + # rust-cache restores target/dist directories between runs. If a previous + # job left bundle outputs behind, later fallback checks can accidentally + # pick stale artifacts. + - name: Clean stale Linux release outputs + shell: bash + run: | + set -euo pipefail + TARGET_ROOT="src-tauri/target/x86_64-unknown-linux-gnu/release" + rm -rf "$TARGET_ROOT/bundle" + rm -f "$TARGET_ROOT/skill" + rm -rf "dist/linux/x86_64-unknown-linux-gnu" + echo "✓ Cleared stale target/dist outputs before Linux release build" + + # ── Compile (frontend + Rust + daemon) ─────────────────────────────── + - name: Compile (frontend + Rust + daemon) + env: + RUSTC_WRAPPER: sccache + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=mold + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache + shell: bash + run: | + set -euo pipefail + # Build the daemon first for consistent feature unification, then the app. + 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 + + # ── Stage files for packaging ─────────────────────────────────────--- + # The daemon binary sits next to the app binary. At runtime the + # Tauri app starts the daemon and calls its /service/install endpoint + # which self-registers a systemd --user service. No system-level + # service file is needed in the package. + - name: Stage files for packaging + shell: bash + run: | + set -euo pipefail + PKGROOT="$RUNNER_TEMP/pkgroot" + mkdir -p "$PKGROOT/opt/NeuroSkill" + + # Copy Tauri app binary + cp src-tauri/target/x86_64-unknown-linux-gnu/release/skill "$PKGROOT/opt/NeuroSkill/skill" + chmod +x "$PKGROOT/opt/NeuroSkill/skill" + + # Copy daemon binary next to the app binary + cp src-tauri/target/x86_64-unknown-linux-gnu/release/skill-daemon "$PKGROOT/opt/NeuroSkill/skill-daemon" + chmod +x "$PKGROOT/opt/NeuroSkill/skill-daemon" + + # Verify both binaries are present + ls -la "$PKGROOT/opt/NeuroSkill/skill" "$PKGROOT/opt/NeuroSkill/skill-daemon" + + # Show staged files + find "$PKGROOT" + + # ── Build Linux portable tarball with daemon and service ───────────── + - name: Build Linux portable tarball (with daemon) + shell: bash + run: | + set -euo pipefail + cd "$RUNNER_TEMP/pkgroot" + tar -czf "$GITHUB_WORKSPACE/NeuroSkill-linux-x86_64.tar.gz" . + + # ── Upload cargo build timings ───────────────────────────────────────── + # The --timings flag produces an HTML report showing per-crate compile + # durations. Upload it so slow crates can be identified and targeted. + - name: Upload cargo build timings + if: always() + uses: actions/upload-artifact@v7 + with: + name: cargo-timings-linux + path: src-tauri/target/cargo-timings/ + retention-days: 30 + + - name: Build Linux deb/rpm via system tools + run: bash scripts/package-linux-system-bundles.sh --target x86_64-unknown-linux-gnu --skip-build + + - name: Build Linux portable tarball + run: bash scripts/package-linux-dist.sh --target x86_64-unknown-linux-gnu --skip-build + + # ── Post-build sanity check ───────────────────────────────────────────── + - name: Verify Linux bundle directories + shell: bash + run: | + set -euo pipefail + TARGET_DIR="src-tauri/target/x86_64-unknown-linux-gnu/release/bundle" + PORTABLE_DIR="dist/linux/x86_64-unknown-linux-gnu" + PORTABLE_TAR_COUNT="$(find "$PORTABLE_DIR" -maxdepth 1 -name "*.tar.gz" | wc -l | tr -d ' ')" + + if [ ! -d "$TARGET_DIR/deb" ] || [ ! -d "$TARGET_DIR/rpm" ] || [ "$PORTABLE_TAR_COUNT" -eq 0 ]; then + echo "::error::Expected Linux bundle directories are missing." + echo "Expected: $TARGET_DIR/deb, $TARGET_DIR/rpm, and at least one tar.gz in $PORTABLE_DIR" + echo "Present under $TARGET_DIR and $PORTABLE_DIR:" + find "$TARGET_DIR" -maxdepth 2 -type d 2>/dev/null | sort || true + find "$PORTABLE_DIR" -maxdepth 2 2>/dev/null | sort || true + exit 1 + fi + + echo "✓ Artifacts exist: $TARGET_DIR/deb, $TARGET_DIR/rpm, $PORTABLE_DIR/*.tar.gz" + + # ── Locate build artifacts ──────────────────────────────────────────────── + - name: Collect artifact paths + id: artifacts + run: | + TARGET_DIR="src-tauri/target/x86_64-unknown-linux-gnu/release/bundle" + PORTABLE_DIR="dist/linux/x86_64-unknown-linux-gnu" + + DEB="$(find "$TARGET_DIR/deb" -maxdepth 1 -name "*.deb" | head -1)" + RPM="$(find "$TARGET_DIR/rpm" -maxdepth 1 -name "*.rpm" | head -1)" + TAR="$(find "$PORTABLE_DIR" -maxdepth 1 -name "*.tar.gz" | head -1)" + + if [ -z "$DEB" ] || [ -z "$RPM" ] || [ -z "$TAR" ]; then + echo "::error::One or more expected artifacts are missing." + echo " DEB : ${DEB:-(not found)}" + echo " RPM : ${RPM:-(not found)}" + echo " TAR : ${TAR:-(not found)}" + exit 1 + fi + + echo "deb=$DEB" >> "$GITHUB_OUTPUT" + echo "rpm=$RPM" >> "$GITHUB_OUTPUT" + echo "portable_tar=$TAR" >> "$GITHUB_OUTPUT" + echo "portable_tar_name=$(basename "$TAR")" >> "$GITHUB_OUTPUT" + + echo "Artifacts found:" + echo " DEB : $DEB ($(du -sh "$DEB" | cut -f1))" + echo " RPM : $RPM ($(du -sh "$RPM" | cut -f1))" + echo " TAR : $TAR ($(du -sh "$TAR" | cut -f1))" + + - name: Write Linux release health metrics + shell: bash + run: | + set -euo pipefail + mkdir -p ci-metrics + + DEB="${{ steps.artifacts.outputs.deb }}" + RPM="${{ steps.artifacts.outputs.rpm }}" + TAR="${{ steps.artifacts.outputs.portable_tar }}" + BIN="src-tauri/target/x86_64-unknown-linux-gnu/release/skill" + + DEB_BYTES="$(stat -c %s "$DEB")" + RPM_BYTES="$(stat -c %s "$RPM")" + TAR_BYTES="$(stat -c %s "$TAR")" + BIN_BYTES="$(stat -c %s "$BIN")" + + { + echo '{' + echo ' "job": "release-linux",' + echo ' "target": "x86_64-unknown-linux-gnu",' + echo ' "binary_bytes":' "$BIN_BYTES" ',' + echo ' "deb_bytes":' "$DEB_BYTES" ',' + echo ' "rpm_bytes":' "$RPM_BYTES" ',' + echo ' "portable_tar_bytes":' "$TAR_BYTES" + echo '}' + } > ci-metrics/release-linux.json + + { + echo "## Linux release health metrics" + echo "- skill binary size: ${BIN_BYTES} bytes" + echo "- .deb size: ${DEB_BYTES} bytes" + echo "- .rpm size: ${RPM_BYTES} bytes" + echo "- portable tarball size: ${TAR_BYTES} bytes" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Linux release health metrics + if: always() + uses: actions/upload-artifact@v7 + with: + name: ci-health-release-linux + path: ci-metrics/release-linux.json + retention-days: 30 + + - name: Generate checksums + detached signatures + id: integrity + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + TARGET_DIR="src-tauri/target/x86_64-unknown-linux-gnu/release/bundle" + CHECKSUMS="$TARGET_DIR/SHA256SUMS-linux-x86_64.txt" + + sha256sum \ + "${{ steps.artifacts.outputs.deb }}" \ + "${{ steps.artifacts.outputs.rpm }}" \ + "${{ steps.artifacts.outputs.portable_tar }}" \ + > "$CHECKSUMS" + + npx tauri signer sign "${{ steps.artifacts.outputs.deb }}" + npx tauri signer sign "${{ steps.artifacts.outputs.rpm }}" + npx tauri signer sign "${{ steps.artifacts.outputs.portable_tar }}" + + echo "checksums=$CHECKSUMS" >> "$GITHUB_OUTPUT" + echo "deb_sig=${{ steps.artifacts.outputs.deb }}.sig" >> "$GITHUB_OUTPUT" + echo "rpm_sig=${{ steps.artifacts.outputs.rpm }}.sig" >> "$GITHUB_OUTPUT" + echo "portable_tar_sig=${{ steps.artifacts.outputs.portable_tar }}.sig" >> "$GITHUB_OUTPUT" + + echo "Integrity artifacts:" + echo " SHA256SUMS : $CHECKSUMS" + echo " DEB SIG : ${{ steps.artifacts.outputs.deb }}.sig" + echo " RPM SIG : ${{ steps.artifacts.outputs.rpm }}.sig" + echo " TAR SIG : ${{ steps.artifacts.outputs.portable_tar }}.sig" + + # ── Create / update GitHub Release with Linux binaries ─────────────────── + # + # softprops/action-gh-release creates the release if it doesn't exist yet, + # or adds files to it if it was already created by the macOS or Windows + # workflow. latest.json is handled in the next step. + # + # Linux assets uploaded here: + # *.deb — Debian/Ubuntu package + # *.rpm — Fedora/RHEL/openSUSE package + # *linux-portable.tar.gz — standalone Linux portable package + # SHA256SUMS-linux-x86_64.txt — checksums for Linux artifacts + # *.deb.sig / *.rpm.sig / *.tar.gz.sig — detached signatures + - name: Prepare changelog notes for release + if: steps.version_meta.outputs.is_release == 'true' + run: | + TAG="${{ steps.version_meta.outputs.tag }}" + PREV_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" + RANGE="${PREV_TAG:+$PREV_TAG..}$TAG" + node scripts/ci.mjs prepare-changelog \ + "${{ steps.version_meta.outputs.version }}" release-notes.md "$RANGE" + + - name: Create / update GitHub Release + if: steps.version_meta.outputs.is_release == 'true' + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.version_meta.outputs.tag }} + name: Skill v${{ steps.version_meta.outputs.version }} + generate_release_notes: false + body_path: release-notes.md + fail_on_unmatched_files: true + prerelease: ${{ steps.version_meta.outputs.prerelease == 'true' }} + files: | + ${{ steps.artifacts.outputs.deb }} + ${{ steps.artifacts.outputs.rpm }} + ${{ steps.artifacts.outputs.portable_tar }} + ${{ steps.integrity.outputs.checksums }} + ${{ steps.integrity.outputs.deb_sig }} + ${{ steps.integrity.outputs.rpm_sig }} + ${{ steps.integrity.outputs.portable_tar_sig }} + + # ── Update latest.json ──────────────────────────────────────────────────── + # + # Downloads the current latest.json from the release (created by whichever + # platform workflow ran first), merges the linux-x86_64 entry, then + # re-uploads. If no latest.json exists yet, creates one from scratch. + - name: Update latest.json + if: steps.version_meta.outputs.is_release == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: >- + node scripts/ci.mjs update-latest-json + --platform linux-x86_64 + --url "https://github.com/${{ github.repository }}/releases/download/${{ steps.version_meta.outputs.tag }}/${{ steps.artifacts.outputs.portable_tar_name }}" + --sig-file "${{ steps.integrity.outputs.portable_tar_sig }}" + --tag "${{ steps.version_meta.outputs.tag }}" + --version "${{ steps.version_meta.outputs.version }}" + --upload + --mirror-to-rc-latest + + # ── Upload CI artifacts (on-demand builds only) ────────────────────────── + - name: Upload build artifacts + if: steps.version_meta.outputs.is_release != 'true' + uses: actions/upload-artifact@v7 + with: + name: NeuroSkill-Linux-${{ steps.version_meta.outputs.version }} + path: | + ${{ steps.artifacts.outputs.deb }} + ${{ steps.artifacts.outputs.rpm }} + ${{ steps.artifacts.outputs.portable_tar }} + retention-days: 14 + + # ── Discord notification ────────────────────────────────────────────────── + - name: Notify Discord of release + if: always() && steps.version_meta.outputs.is_release == 'true' + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: >- + node scripts/ci.mjs discord-notify + --status "${{ job.status }}" + --title "${{ job.status == 'success' && '🚀' || '💥' }} NeuroSkill™ v${{ steps.version_meta.outputs.version }} — Linux ${{ job.status == 'success' && 'Published' || 'Failed' }}" + --version "${{ steps.version_meta.outputs.version }}" + --tag "${{ steps.version_meta.outputs.tag }}" + --platform "Linux x86_64" + --release-url "${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ steps.version_meta.outputs.tag }}" + --run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml new file mode 100644 index 000000000..b3f7838e7 --- /dev/null +++ b/.github/workflows/release-mac.yml @@ -0,0 +1,466 @@ +name: Release - Mac + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+' + workflow_dispatch: + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + # WidgetKit signing currently breaks Apple notarization (Invalid). Set to + # 'true' to re-enable once appex signing/timestamps are fixed. + ENABLE_WIDGETKIT: 'false' + +jobs: + release: + name: Build & Release — aarch64-apple-darwin + runs-on: macos-26 + environment: Release + + steps: + # ── Checkout ────────────────────────────────────────────────────────────── + - name: Checkout + uses: actions/checkout@v6 + 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 + id: version_meta + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_REF: ${{ github.ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: node scripts/ci.mjs resolve-version + + # ── Rust toolchain ──────────────────────────────────────────────────────── + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + + # ── Cargo cache ─────────────────────────────────────────────────────────── + # Swatinem/rust-cache is purpose-built for Rust: it strips test artifacts, + # stale deps, and other bloat before saving, keeping the cache lean. + - name: Cache Cargo registry + build artifacts + uses: Swatinem/rust-cache@v2.9.1 + with: + workspaces: ". -> src-tauri/target" + cache-targets: false + cache-on-failure: false + + # ── Build cache (sccache) ───────────────────────────────────────────────── + - name: Setup sccache + uses: Mozilla-Actions/sccache-action@v0.0.9 + + - name: Configure sccache for GitHub Actions cache + shell: bash + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=10G" >> "$GITHUB_ENV" + echo "SCCACHE_GHA_VERSION=1" >> "$GITHUB_ENV" + + # ── Node.js ─────────────────────────────────────────────────────────────── + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Update npm to latest + run: npm install -g npm@latest + + - name: Install JS dependencies + run: npm ci + + - name: Install protoc + GNU ar (macOS) + run: | + brew install protobuf binutils + # Use GNU ar to suppress "illegal option -- D" warnings from Apple's system ar + echo "AR=$(brew --prefix binutils)/bin/gar" >> "$GITHUB_ENV" + + # ── Pre-flight: verify every secret is set ─────────────────────────────── + # Catches missing / renamed secrets in seconds instead of after a 15-minute + # build. Each variable is checked for non-empty; none of the values are + # printed. + - name: Verify signing and notarization secrets are present + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: >- + node scripts/ci.mjs verify-secrets + APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY + APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY + + # ── Pre-flight: validate APPLE_SIGNING_IDENTITY is the right cert type ─── + # Only `Developer ID Application` certs pass Gatekeeper for distribution. + # Apple Development / Mac Developer / ad-hoc / self-signed all silently + # break the runtime Safari extension flow on end-user Macs (Safari refuses + # to load extensions from Gatekeeper-rejected apps). Fail fast here. + - name: Validate Apple signing identity is Developer ID Application + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: node scripts/ci.mjs validate-apple-signing-identity + + # ── Pre-flight: validate Apple notarization credentials ─────────────────── + # xcrun notarytool history authenticates against Apple's API using the same + # credentials the build will use, but without submitting anything. + # A 401 here means the app-specific password is wrong or expired. + # Generate a fresh one at https://appleid.apple.com → App-Specific Passwords, + # then update the APPLE_PASSWORD secret in the repository environment. + - name: Validate Apple notarization credentials + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: node scripts/ci.mjs validate-notarization + + # ── espeak-ng static library ────────────────────────────────────────────── + # ── Apple Developer certificate → temporary keychain ───────────────────── + # + # Secrets required: + # APPLE_CERTIFICATE — base64-encoded .p12 (Developer ID Application cert) + # APPLE_CERTIFICATE_PASSWORD — password protecting the .p12 + # + # How to export the .p12: + # Keychain Access → right-click cert → Export → Personal Information Exchange + # Then: base64 -i cert.p12 | pbcopy (paste into the GitHub secret) + - name: Import Apple Developer certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: node scripts/ci.mjs import-apple-cert + + # ── WidgetKit extension (optional; off until notarization-safe) ───────── + - name: Install XcodeGen + if: env.ENABLE_WIDGETKIT == 'true' + run: brew install xcodegen + + - name: Setup WidgetKit signing + if: env.ENABLE_WIDGETKIT == 'true' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_ASC_KEY_ID: ${{ secrets.APPLE_ASC_KEY_ID }} + APPLE_ASC_KEY_ISSUER_ID: ${{ secrets.APPLE_ASC_KEY_ISSUER_ID }} + APPLE_ASC_KEY_BASE64: ${{ secrets.APPLE_ASC_KEY_BASE64 }} + APPLE_WIDGET_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_WIDGET_PROVISIONING_PROFILE_BASE64 }} + run: node scripts/ci.mjs setup-widget-signing + + - name: Build WidgetKit extension + if: env.ENABLE_WIDGETKIT == 'true' + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + AUTH_KEY_PATH: ${{ env.AUTH_KEY_PATH }} + AUTH_KEY_ID: ${{ env.AUTH_KEY_ID }} + AUTH_KEY_ISSUER_ID: ${{ env.AUTH_KEY_ISSUER_ID }} + run: node scripts/ci.mjs build-widgets + + # ── Compile: frontend + Rust binary + sidecar ─────────────────────────── + # + # `npx tauri build` crashes on macOS 26 with SIGILL *after* compilation, + # inside the Tauri CLI napi binary's bundling code. The crash occurs + # regardless of --no-sign, pointing to the icon/resource pipeline (likely + # SIMD image-processing code that emits ud2 on this CPU). + # + # Fix: invoke the two compile steps directly and assemble the .app in the + # next step, bypassing the Tauri CLI bundler entirely. + # + # npm run build → SvelteKit/Vite frontend (output: build/, embedded by + # generate_context!() when custom-protocol is enabled) + # cargo build → Rust binary; build.rs also copies espeak-ng-data into + # src-tauri/resources/ so it is ready for bundling. + # + # --features custom-protocol is required: without it Tauri compiles in + # dev mode and the binary tries to load the UI from localhost:1420 + # instead of serving the embedded SvelteKit build output. The Tauri + # CLI (`npx tauri build`) injects this feature automatically, but we + # bypass the CLI here to avoid its post-compilation SIGILL. + - name: Compile (frontend + Rust + sidecar) + shell: bash + env: + RUSTC_WRAPPER: sccache + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache + run: | + set -euo pipefail + npm run build + npm run -s verify:tauri:frontend + # 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() { + cargo build -p skill-daemon --release --locked --target aarch64-apple-darwin --timings || return $? + cargo build -p skill-tty --release --locked --target aarch64-apple-darwin --timings || return $? + cargo build -p skill --release --locked --target aarch64-apple-darwin --features custom-protocol --timings || return $? + } + if ! run_cmd; then + exit 1 + fi + + # ── Assemble + sign .app bundle ───────────────────────────────────── + - name: Create .app bundle + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + FRONTEND_BUILD_DIR: build + SKIP_WIDGETS: ${{ env.ENABLE_WIDGETKIT == 'true' && '0' || '1' }} + run: bash scripts/assemble-macos-app.sh aarch64-apple-darwin + + # ── Smoke-test bundled binaries ────────────────────────────────────── + - name: Smoke-test bundled binaries + run: | + set -euo pipefail + APP_DIR="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/NeuroSkill.app" + + echo "── codesign verification ──" + codesign --verify --deep --strict "$APP_DIR" + echo "✓ codesign --verify passed" + + echo "── daemon binary check ──" + DAEMON_BIN="$APP_DIR/Contents/MacOS/skill-daemon.app/Contents/MacOS/skill-daemon" + file "$DAEMON_BIN" + file "$DAEMON_BIN" | grep -q "Mach-O.*arm64" \ + || { echo "::error::skill-daemon is not a valid arm64 Mach-O binary"; exit 1; } + echo "✓ skill-daemon is a valid arm64 Mach-O binary" + + echo "── main binary architecture check ──" + MAIN_BIN="$APP_DIR/Contents/MacOS/NeuroSkill" + lipo -info "$MAIN_BIN" + lipo -info "$MAIN_BIN" | grep -q "arm64" \ + || { echo "::error::NeuroSkill binary does not contain arm64 architecture"; exit 1; } + echo "✓ NeuroSkill binary contains arm64 architecture" + + # ── Upload cargo build timings ───────────────────────────────────────── + # The --timings flag produces an HTML report showing per-crate compile + # durations. Upload it so slow crates can be identified and targeted. + - name: Upload cargo build timings + if: always() + uses: actions/upload-artifact@v7 + with: + name: cargo-timings-macos + path: src-tauri/target/cargo-timings/ + retention-days: 30 + + # ── Create DMG (background, README, CHANGELOG, LICENSE) ──────────────── + # + # Uses sindresorhus/create-dmg via scripts/create-macos-dmg.sh which: + # • Creates DMG with composed icon, Retina background, SLA + # • Notarizes with Apple (staples both DMG and .app) + - name: Install DMG dependencies + run: | + # npm@latest no longer runs dependency install scripts by default, so + # appdmg's native deps (fs-xattr, macos-alias) skip their node-gyp build + # and `volume.node` goes missing. Allowlist their scripts explicitly. + npm install --global appdmg --allow-scripts=fs-xattr,macos-alias + pip3 install --quiet --break-system-packages Pillow + + - name: Create DMG + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + echo "Creating DMG with version ${{ steps.version_meta.outputs.version }}" + bash scripts/create-macos-dmg.sh aarch64-apple-darwin + # Verify the DMG was created with the correct version + DMG_FILE=$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name "*.dmg" -maxdepth 1 | head -1) + if [[ "$DMG_FILE" == *"${{ steps.version_meta.outputs.version }}"* ]]; then + echo "✓ DMG filename contains correct version: ${{ steps.version_meta.outputs.version }}" + else + echo "::error::DMG filename does not contain expected version" + exit 1 + fi + + # ── Create updater artifact from the signed + stapled .app ───────────── + # + # Runs after Create DMG so the .app is already notarized and stapled. + # The Tauri bundler never ran, so there is no stale .tar.gz to clean up. + # This step creates the updater bundle from scratch and signs it with the + # Ed25519 key. `npx tauri signer sign` is safe here — it is pure + # cryptography with no macOS-API bundling code, so it does not hit the + # SIGILL that kills `npx tauri build`. + - name: Recreate updater artifact from signed .app + if: steps.version_meta.outputs.is_release == 'true' + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + BUNDLE_DIR="src-tauri/target/aarch64-apple-darwin/release/bundle" + APP_BUNDLE="$(find "$BUNDLE_DIR/macos" -name "*.app" -maxdepth 1 | head -1)" + + # No stale artifacts to remove (Tauri bundler never ran). + # Belt-and-suspenders: delete any partial files if this step retries. + find "$BUNDLE_DIR/macos" \ + \( -name "*.app.tar.gz" -o -name "*.app.tar.gz.sig" \) -delete + + # Create tarball from the signed + notarized + stapled .app. + # Use an ASCII-only filename for the tarball so the URL stored in + # latest.json is a valid RFC 3986 URI; non-ASCII characters such as ™ + # (U+2122) in the filename cause the Tauri updater to produce a 404 + # because its HTTP client rejects or misroutes an un-encoded URL. + # The .app bundle inside the tar keeps its original NeuroSkill.app name. + VERSION="${{ steps.version_meta.outputs.version }}" + TAR_PATH="$BUNDLE_DIR/macos/NeuroSkill_${VERSION}_aarch64.app.tar.gz" + tar -czf "$TAR_PATH" \ + -C "$(dirname "$APP_BUNDLE")" "$(basename "$APP_BUNDLE")" + + # Sign with Tauri's Ed25519 key. + # FILE is a positional argument in Tauri v2; -f is now --private-key-path + # and would conflict with the TAURI_SIGNING_PRIVATE_KEY env var (--private-key). + npx tauri signer sign "$TAR_PATH" + + # ── Always delete the temporary keychain ────────────────────────────────── + - name: Delete temporary keychain + if: always() + run: security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true + + # ── Locate build artifacts ──────────────────────────────────────────────── + - name: Collect artifact paths + if: steps.version_meta.outputs.is_release == 'true' + id: artifacts + run: | + BUNDLE_DIR="src-tauri/target/aarch64-apple-darwin/release/bundle" + + APP_TAR="$(find "$BUNDLE_DIR/macos" \ + -name "*.app.tar.gz" ! -name "*.sig" \ + -maxdepth 1 | head -1)" + APP_SIG="$(find "$BUNDLE_DIR/macos" \ + -name "*.app.tar.gz.sig" \ + -maxdepth 1 | head -1)" + DMG="$(find "$BUNDLE_DIR/dmg" \ + -name "*.dmg" \ + -maxdepth 1 | head -1)" + + if [ -z "$APP_TAR" ] || [ -z "$APP_SIG" ] || [ -z "$DMG" ]; then + echo "::error::One or more expected artifacts are missing." + echo " APP_TAR : $APP_TAR" + echo " APP_SIG : $APP_SIG" + echo " DMG : $DMG" + exit 1 + fi + + echo "app_tar=$APP_TAR" >> "$GITHUB_OUTPUT" + echo "app_sig=$APP_SIG" >> "$GITHUB_OUTPUT" + echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + echo "app_tar_name=$(basename "$APP_TAR")" >> "$GITHUB_OUTPUT" + echo "dmg_name=$(basename "$DMG")" >> "$GITHUB_OUTPUT" + + echo "Artifacts found:" + echo " TAR : $APP_TAR ($(du -sh "$APP_TAR" | cut -f1))" + echo " SIG : $APP_SIG" + echo " DMG : $DMG ($(du -sh "$DMG" | cut -f1))" + + # ── Create GitHub Release + upload macOS binary assets ─────────────────── + # + # Creates (or updates) the release and uploads the macOS-specific binaries. + # latest.json is handled in the next step so that the Windows and Linux + # release workflows can each merge their own platform entry without + # overwriting each other. + # + # macOS assets uploaded here: + # NeuroSkill__aarch64.dmg — drag-to-install disk image + # NeuroSkill.app.tar.gz — Tauri updater bundle + # NeuroSkill.app.tar.gz.sig — Ed25519 signature for the updater + - name: Prepare changelog notes for release + if: steps.version_meta.outputs.is_release == 'true' + run: | + TAG="${{ steps.version_meta.outputs.tag }}" + PREV_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" + RANGE="${PREV_TAG:+$PREV_TAG..}$TAG" + node scripts/ci.mjs prepare-changelog \ + "${{ steps.version_meta.outputs.version }}" release-notes.md "$RANGE" + + - name: Create GitHub Release + if: steps.version_meta.outputs.is_release == 'true' + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.version_meta.outputs.tag }} + name: Skill v${{ steps.version_meta.outputs.version }} + generate_release_notes: false + body_path: release-notes.md + fail_on_unmatched_files: true + # `prerelease` is derived from the version string: any `-rc.N` suffix + # marks the build as a release candidate. GitHub treats prereleases + # specially — `releases/latest/download/...` URLs skip them — so the + # stable update channel automatically ignores RCs without any client + # configuration. RC users hit the rc-latest mirror updated below. + prerelease: ${{ steps.version_meta.outputs.prerelease == 'true' }} + files: | + ${{ steps.artifacts.outputs.dmg }} + ${{ steps.artifacts.outputs.app_tar }} + ${{ steps.artifacts.outputs.app_sig }} + + # ── Update latest.json ──────────────────────────────────────────────────── + # + # Downloads the current latest.json from the release (if a Windows or Linux + # platform workflow already uploaded one), merges the darwin-aarch64 entry, + # then re-uploads via `gh release upload --clobber`. + # If no latest.json exists yet this step creates one from scratch. + # + # All three platform release workflows (macOS, Windows, Linux) use the same + # download → merge → upload pattern, so the single file at: + # https://github.com/NeuroSkill-com/skill/releases/latest/download/latest.json + # accumulates entries for every platform as each build completes. + # The Tauri updater reads only its own platform key (e.g. "darwin-aarch64") + # and ignores the rest, so a partial manifest is valid while other platform + # builds are still in flight. + - name: Update latest.json + if: steps.version_meta.outputs.is_release == 'true' + env: + GH_TOKEN: ${{ github.token }} + # `--mirror-to-rc-latest` also pushes the manifest to the mutable + # `rc-latest` GitHub Release, which is the URL the RC update channel + # polls. Both stable and RC builds mirror, so RC opt-in users always + # see the most recent build of either kind. The release is bootstrapped + # automatically on the first run via cmdEnsureRcLatestRelease. + run: >- + node scripts/ci.mjs update-latest-json + --platform darwin-aarch64 + --url "https://github.com/${{ github.repository }}/releases/download/${{ steps.version_meta.outputs.tag }}/${{ steps.artifacts.outputs.app_tar_name }}" + --sig-file "${{ steps.artifacts.outputs.app_sig }}" + --tag "${{ steps.version_meta.outputs.tag }}" + --version "${{ steps.version_meta.outputs.version }}" + --upload + --mirror-to-rc-latest + + # ── Upload CI artifacts (manual / on-demand builds only) ──────────────── + - name: Upload build artifacts + if: steps.version_meta.outputs.is_release != 'true' + uses: actions/upload-artifact@v7 + with: + name: NeuroSkill-macOS-${{ steps.version_meta.outputs.version }} + path: | + src-tauri/target/aarch64-apple-darwin/release/bundle/macos/*.app + src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + retention-days: 14 + + # ── Discord notification ──────────────────────────────────────────────── + - name: Notify Discord of release + if: always() && steps.version_meta.outputs.is_release == 'true' + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: >- + node scripts/ci.mjs discord-notify + --status "${{ job.status }}" + --title "${{ job.status == 'success' && '🚀' || '💥' }} NeuroSkill™ v${{ steps.version_meta.outputs.version }} — macOS ${{ job.status == 'success' && 'Published' || 'Failed' }}" + --version "${{ steps.version_meta.outputs.version }}" + --tag "${{ steps.version_meta.outputs.tag }}" + --platform "macOS aarch64" + --release-url "${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ steps.version_meta.outputs.tag }}" + --run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml new file mode 100644 index 000000000..38a33da55 --- /dev/null +++ b/.github/workflows/release-windows.yml @@ -0,0 +1,885 @@ +name: Release — Windows + +# Triggered by the same semver tag as the macOS and Linux release workflows. +# All three run in parallel; each uploads its own binaries and then merges +# its platform entry into the shared latest.json updater manifest. +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+' + workflow_dispatch: + inputs: + dry_run: + description: 'CI test only — build but skip release upload' + type: boolean + default: true + +permissions: + contents: write # create/update GitHub Releases and upload assets + +# Opt into Node.js 24 now — same rationale as ci.yml. +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + release-windows: + name: Build & Release — x86_64-pc-windows-msvc + runs-on: windows-latest + environment: ${{ inputs.dry_run && 'CI-Test' || 'Release' }} + + steps: + # ── Checkout ────────────────────────────────────────────────────────────── + - name: Checkout + uses: actions/checkout@v6 + 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 + + # ── Resolve version ───────────────────────────────────────────────────── + # Tag push: verify tag matches tauri.conf.json. + # workflow_dispatch: read version from tauri.conf.json directly. + - name: Resolve version + id: version_meta + shell: bash + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_REF: ${{ github.ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + run: node scripts/ci.mjs resolve-version + + # ── MSVC developer environment ──────────────────────────────────────────── + # Puts cl.exe, link.exe, lib.exe, and signtool.exe in PATH. + # Required for the espeak-ng static library build (lib.exe) and for + # Tauri's Windows code-signing step (signtool.exe). + - name: Set up MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + + - name: Validate Windows app manifest with mt.exe + shell: powershell + run: | + $mt = Get-Command mt.exe -ErrorAction SilentlyContinue + if (-not $mt) { + Write-Error "mt.exe not found in PATH" + exit 1 + } + & $mt.Source -nologo -manifest src-tauri/manifest.xml -validate_manifest + if ($LASTEXITCODE -ne 0) { + Write-Error "mt.exe manifest validation failed" + exit $LASTEXITCODE + } + Write-Host "[ok] mt.exe manifest validation passed" + + # ── Remove Git's link.exe and tar.exe ──────────────────────────────────── + # Git for Windows prepends C:\Program Files\Git\usr\bin to PATH at every + # step via $GITHUB_PATH (re-applied each step, so runtime PATH changes + # don't survive — only direct file deletion works). Two binaries cause + # problems: + # + # link.exe — Unix hard-link utility; shadows MSVC's linker, producing + # "extra operand" build errors. + # tar.exe — GNU tar used by actions/cache with --posix, which strips + # or corrupts non-POSIX Windows paths in the saved archive, + # breaking cache restores. Deleting it forces the use of + # C:\Windows\System32\tar.exe (built-in bsdtar 3.x). + - name: Remove Git's link.exe and tar.exe (shadow MSVC linker / corrupt cache) + shell: powershell + run: | + foreach ($bin in @("link.exe", "tar.exe")) { + $path = "C:\Program Files\Git\usr\bin\$bin" + if (Test-Path $path) { + Remove-Item $path -Force + Write-Host "[ok] Deleted $path" + } else { + Write-Host "[skip] $path not found" + } + } + + # ── Rust toolchain ──────────────────────────────────────────────────────── + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + # ── Cargo cache ─────────────────────────────────────────────────────────── + - name: Cache Cargo registry + build artifacts + uses: Swatinem/rust-cache@v2.9.1 + with: + workspaces: ". -> src-tauri/target" + shared-key: windows-release-x86_64-msvc-v2 + # Save cache even when the job fails (e.g. a post-build step + # errors out). Without this, a late-stage failure means the + # 57-min compile output is discarded and the next run starts + # from scratch — a vicious cycle. + save-if: "always()" + # Don't cache final compiled artifacts — registry + incremental + # data is enough; target/ artifacts recompile quickly from it. + cache-targets: false + cache-on-failure: false + + # ── ONNX Runtime — not needed on Windows ────────────────────────────── + # KittenTTS (the only `ort` consumer) is gated to non-Windows targets in + # src-tauri / skill-tts Cargo.toml, so Windows builds don't link or ship + # onnxruntime.dll. Add the install/cache steps back here if a Windows + # TTS backend that needs ONNX Runtime is reintroduced. + + # ── Build cache (sccache) ───────────────────────────────────────────────── + - name: Setup sccache + uses: Mozilla-Actions/sccache-action@v0.0.9 + + - name: Configure sccache for GitHub Actions cache + shell: bash + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "SCCACHE_CACHE_SIZE=10G" >> "$GITHUB_ENV" + # Version the sccache GHA cache so it can be busted manually by + # incrementing this value (e.g. after a toolchain or LLVM upgrade). + echo "SCCACHE_GHA_VERSION=2" >> "$GITHUB_ENV" + + # Verify sccache is working + sccache --show-stats + echo "[ok] RUSTC_WRAPPER=${RUSTC_WRAPPER:-}" + + # ── Node.js + frontend build ───────────────────────────────────────────── + # Moved before SDK installs so the frontend compiles while heavy SDKs + # (Vulkan, LLVM, espeak) are set up afterwards — no dependency between + # the two. On cache-warm runs the frontend finishes in < 30 s, so the + # wall-clock saving comes from not blocking the SDK steps behind it. + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + + - name: Update npm to latest + run: npm install -g npm@latest + + - name: Install JS dependencies + run: npm ci + + - name: Install protoc (Windows) + run: node scripts/ci.mjs install-protoc-windows + + + - name: Build frontend + run: npm run build + + - name: Verify Tauri frontend bundle structure + run: npm run -s verify:tauri:frontend + + # ── NSIS + Vulkan SDK (parallel install) ───────────────────────────────── + # NSIS and the Vulkan SDK are independent of each other and of the Rust + # 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 + # Bump the suffix when upgrading the Vulkan SDK version, since the + # install script downloads "latest" and the cache would serve stale files. + key: windows-vulkan-sdk-v1-${{ hashFiles('scripts/install-vulkan-sdk.ps1') }} + + - name: Cache NSIS + uses: actions/cache@v5 + with: + path: | + C:\Program Files (x86)\NSIS + C:\nsis + key: windows-nsis-v1 + + - name: Install NSIS + Vulkan SDK (parallel) + shell: powershell + run: | + # ── NSIS (background) ──────────────────────────────────────────────── + $nsisJob = Start-Job -ScriptBlock { + $makensis = Get-Command makensis -ErrorAction SilentlyContinue + if ($makensis) { + Write-Output "[ok] NSIS already on PATH: $($makensis.Source)" + return + } + + # Try Chocolatey first, fall back to direct download + $installed = $false + for ($i = 1; $i -le 2; $i++) { + try { + Write-Output "[info] NSIS not found; installing via Chocolatey (attempt $i)" + choco install nsis -y --no-progress 2>&1 + if ($LASTEXITCODE -eq 0) { $installed = $true; break } + } catch { + Write-Output "[warn] choco attempt $i failed: $($_.Exception.Message)" + } + Start-Sleep -Seconds (3 * $i) + } + + if (-not $installed) { + Write-Output "[warn] Chocolatey unavailable; falling back to direct NSIS download" + $nsisVer = "3.10" + $url = "https://sourceforge.net/projects/nsis/files/NSIS%203/$nsisVer/nsis-$nsisVer.zip/download" + $zip = Join-Path $env:TEMP "nsis-$nsisVer.zip" + $dest = "C:\nsis" + Invoke-WebRequest -Uri $url -OutFile $zip -UserAgent "Mozilla/5.0" -MaximumRedirection 10 + Expand-Archive -Path $zip -DestinationPath $dest -Force + # The zip extracts into a subdirectory + $innerDir = Get-ChildItem -Path $dest -Directory | Select-Object -First 1 + if ($innerDir) { + # Move contents up so makensis.exe is at C:\nsis\makensis.exe + Get-ChildItem -Path $innerDir.FullName | Move-Item -Destination $dest -Force + Remove-Item $innerDir.FullName -Recurse -Force -ErrorAction SilentlyContinue + } + if (-not (Test-Path (Join-Path $dest "makensis.exe"))) { + throw "NSIS direct download failed: makensis.exe not found in $dest" + } + Write-Output "[ok] Installed NSIS $nsisVer via direct download to $dest" + } + } + + # ── Vulkan SDK (foreground — needs env export) ─────────────────────── + & .\scripts\install-vulkan-sdk.ps1 + if (-not $env:VULKAN_SDK) { + Write-Error "VULKAN_SDK was not set by install-vulkan-sdk.ps1" + exit 1 + } + "VULKAN_SDK=$env:VULKAN_SDK" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "[ok] Exported VULKAN_SDK=$env:VULKAN_SDK" + + # ── Wait for NSIS ──────────────────────────────────────────────────── + $nsisJob | Wait-Job | Receive-Job + if ($nsisJob.State -eq 'Failed') { + Write-Error "NSIS background install failed" + exit 1 + } + + # Locate makensis and export NSIS_DIR + $makensis = Get-Command makensis -ErrorAction SilentlyContinue + if ($makensis) { + $nsisDir = Split-Path -Parent $makensis.Source + } else { + $candidatePaths = @( + "C:\Program Files (x86)\NSIS", + "C:\Program Files\NSIS", + "$env:LOCALAPPDATA\NSIS", + "C:\nsis" + ) + $nsisDir = $null + foreach ($path in $candidatePaths) { + if (Test-Path (Join-Path $path "makensis.exe")) { + $nsisDir = $path + break + } + } + } + + if (-not $nsisDir -or -not (Test-Path (Join-Path $nsisDir "makensis.exe"))) { + Write-Error "NSIS installation succeeded but makensis.exe is still not discoverable" + exit 1 + } + + "NSIS_DIR=$nsisDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $nsisDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + Write-Host "[ok] NSIS_DIR=$nsisDir" + + # ── OpenBLAS (upstream binary release) ──────────────────────────────────── + # rlx-cpu emits `cargo:rustc-link-lib=openblas` on all non-macOS targets, + # so the Windows link step needs `openblas.lib` + `openblas.dll`. We + # download the upstream OpenBLAS Windows archive because vcpkg's port + # omits LAPACK and rlx-cpu's `blas` feature calls dgesv_/sgesv_. + # Cached so the download only happens once per script change. + - name: Cache OpenBLAS (upstream Windows binary) + uses: actions/cache@v5 + with: + path: C:\OpenBLAS + key: windows-openblas-${{ hashFiles('scripts/install-openblas-windows.ps1') }} + + - name: Install OpenBLAS + export OPENBLAS_DIR + shell: powershell + run: .\scripts\install-openblas-windows.ps1 + + # ── Ensure VC++ Redistributable DLLs are available ─────────────────────── + # The binary uses dynamic CRT (/MD). The NSIS installer bundles the CRT + # DLLs app-locally alongside skill.exe. This step ensures they exist on + # the runner — either via the VS installation or by downloading the + # official VC++ Redistributable from Microsoft. + - name: Ensure VC++ CRT DLLs available + id: vcredist + shell: powershell + run: | + & (Join-Path $env:GITHUB_WORKSPACE "scripts/ensure-vcredist.ps1") + + # Export for subsequent steps + if (-not $env:CRT_REDIST_DIR) { + Write-Error "ensure-vcredist.ps1 did not set CRT_REDIST_DIR" + exit 1 + } + + # ── Pre-flight: verify every required secret is set ────────────────────── + # Fails fast (before the 15-min build) if any secret is missing. + # Values are never printed. + # + # WINDOWS_CERTIFICATE — base64-encoded .pfx code-signing certificate + # (OV or EV cert from DigiCert, Sectigo, etc.) + # How to encode: + # certutil -encode cert.pfx cert.b64 + # — or on PowerShell: + # [Convert]::ToBase64String([IO.File]::ReadAllBytes("cert.pfx")) + # + # WINDOWS_CERTIFICATE_PASSWORD — password protecting the .pfx + # Optional — if omitted the installer is built + # unsigned (SmartScreen will warn on first run). + # + # TAURI_SIGNING_PRIVATE_KEY — Ed25519 private key for signing Tauri + # updater artifacts (same key as macOS/Linux) + - name: Verify signing secrets are present + if: steps.version_meta.outputs.dry_run != 'true' + id: signing_meta + shell: bash + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: | + # TAURI_SIGNING_PRIVATE_KEY is always required (updater artifact signing). + if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then + echo "::error::Secret 'TAURI_SIGNING_PRIVATE_KEY' is empty or not set." + exit 1 + fi + echo "[ok] TAURI_SIGNING_PRIVATE_KEY is present." + + # Windows code-signing certificate is optional. + # Both variables must be set together or both must be absent. + HAVE_CERT=false + if [ -n "$WINDOWS_CERTIFICATE" ] && [ -n "$WINDOWS_CERTIFICATE_PASSWORD" ]; then + HAVE_CERT=true + elif [ -n "$WINDOWS_CERTIFICATE" ] || [ -n "$WINDOWS_CERTIFICATE_PASSWORD" ]; then + echo "::error::WINDOWS_CERTIFICATE and WINDOWS_CERTIFICATE_PASSWORD" \ + "must both be set or both be absent." + exit 1 + fi + + if [ "$HAVE_CERT" = "true" ]; then + echo "[ok] Windows code-signing certificate is present - installer will be signed." + else + echo "::warning::WINDOWS_CERTIFICATE is not set." \ + "The NSIS installer will be built unsigned." \ + "Windows SmartScreen may show a warning to end-users." + fi + + # Expose for later steps. + echo "have_windows_cert=$HAVE_CERT" >> "$GITHUB_OUTPUT" + echo "HAVE_WINDOWS_CERT=$HAVE_CERT" >> "$GITHUB_ENV" + + # ── espeak-ng MSVC static library ───────────────────────────────────────── + # Cache keyed on the build script; a script change busts the cache. + # The PS1 script requires MSVC tools (lib.exe) — set up above via + # ilammy/msvc-dev-cmd before this step runs. + # ── Import Windows code-signing certificate (optional) ─────────────────── + # Skipped when WINDOWS_CERTIFICATE is not set (unsigned build). + # When present: imports the .pfx into the Current User certificate store, + # captures the SHA-1 thumbprint, and exposes it as CERTIFICATE_THUMBPRINT + # for the build step. The temp .pfx file is deleted immediately after import. + # + # SmartScreen note: an OV (Organisation Validated) certificate will still + # trigger SmartScreen warnings on first run until the binary accumulates + # reputation. An EV (Extended Validation) certificate skips this entirely. + - name: Import Windows code-signing certificate + if: steps.version_meta.outputs.dry_run != 'true' && steps.signing_meta.outputs.have_windows_cert == 'true' + shell: powershell + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $certBytes = [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) + $certPath = Join-Path $env:RUNNER_TEMP "signing.pfx" + [IO.File]::WriteAllBytes($certPath, $certBytes) + + $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD ` + -AsPlainText -Force + $cert = Import-PfxCertificate ` + -FilePath $certPath ` + -CertStoreLocation Cert:\CurrentUser\My ` + -Password $password + + Remove-Item $certPath -Force + + $thumbprint = $cert.Thumbprint + "CERTIFICATE_THUMBPRINT=$thumbprint" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + $expiry = $cert.NotAfter.ToString("yyyy-MM-dd") + Write-Host "[ok] Certificate imported" + Write-Host " Thumbprint : $thumbprint" + Write-Host " Subject : $($cert.Subject)" + Write-Host " Expires : $expiry" + + if ($cert.NotAfter -lt (Get-Date)) { + Write-Error "::error::Code-signing certificate has expired ($expiry). Renew it." + exit 1 + } + + # ── Standalone LLVM 19 for bindgen + lld-link ────��──────────────────────── + # VS2022's bundled clang-cl can't handle __builtin_ia32_* intrinsics in + # mmintrin.h, causing bindgen to fail. Standalone LLVM 19 fixes this + # and also provides lld-link for faster linking. + # + # Cache the full install so subsequent runs skip the ~500 MB download. + - name: Cache LLVM 19 + id: llvm-cache + uses: actions/cache@v5 + with: + path: C:\LLVM + key: windows-llvm-19-v1 + + - name: Install LLVM 19 + if: steps.llvm-cache.outputs.cache-hit != 'true' + uses: KyleMayes/install-llvm-action@v2.0.9 + with: + version: "19" + directory: C:\LLVM + + - name: Export LLVM env vars + shell: powershell + run: | + $bin = "C:\LLVM\bin" + if (-not (Test-Path "$bin\libclang.dll")) { + # bin/ may be nested one level deeper + $dll = Get-ChildItem "C:\LLVM" -Recurse -Filter "libclang.dll" -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $dll) { Write-Error "libclang.dll not found under C:\LLVM"; exit 1 } + $bin = Split-Path $dll.FullName + } + + @( + "LIBCLANG_PATH=$bin", + "LLVM_CONFIG=$bin\llvm-config.exe" + ) | ForEach-Object { + $_ | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "[ok] $_" + } + + # Use lld-link for faster linking if available + $lldLink = Join-Path $bin "lld-link.exe" + if (Test-Path $lldLink) { + "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER=$lldLink" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "[ok] CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER=$lldLink" + } else { + Write-Host "[info] lld-link not found; falling back to MSVC link.exe" + } + + # ── Compile Rust (cargo build only) ────────────────────────────────────── + # + # Split from packaging so CI timing shows compile vs NSIS separately. + # + # --features custom-protocol: required — without it Tauri compiles in + # dev mode and loads UI from localhost:1420 instead of the embedded + # SvelteKit build output. + - name: Compile (Rust) + shell: bash + env: + VULKAN_SDK: ${{ env.VULKAN_SDK }} + LIBCLANG_PATH: ${{ env.LIBCLANG_PATH }} + # RUSTC_WRAPPER is set automatically by sccache-action. + SCCACHE_GHA_ENABLED: "true" + SCCACHE_CACHE_SIZE: "10G" + # Dynamic CRT: native deps (DirectML, ONNX Runtime, Vulkan loader) + # ship as pre-built /MD DLLs. Using /MD everywhere avoids CRT + # mismatch. The NSIS installer bundles the VC++ Redist DLLs + # app-locally so end users don't need a separate install. + CMAKE_MSVC_RUNTIME_LIBRARY: MultiThreadedDLL + # Let sccache cache C/C++ compilations from cmake-based -sys crates + # (espeak-ng, etc.). Without these, only rustc + # invocations go through sccache — the heavy cmake builds are + # re-done from scratch every time. + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache + run: | + set -euo pipefail + + # Diagnostic: verify sccache is reachable by cargo + echo "[diag] RUSTC_WRAPPER=${RUSTC_WRAPPER:-}" + if [ -n "${RUSTC_WRAPPER:-}" ]; then + "$RUSTC_WRAPPER" --version || echo "[warn] RUSTC_WRAPPER binary not executable" + fi + sccache --show-stats || true + + # 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 || return $? + } + + if ! run_cmd; then + exit 1 + fi + + sccache --show-stats || true + + # rlx-cpu and turbovec resolve cblas_sgemm via openblas.dll at runtime. + # Bundle it next to skill.exe / skill_daemon.exe so the installed app + # works on machines without OpenBLAS preinstalled. The upstream 0.3.30 + # DLL is statically linked against the MinGW runtime (depends only on + # KERNEL32.dll + msvcrt.dll), so no extra runtime DLLs need shipping. + - name: Stage openblas.dll for packaging + shell: powershell + run: | + if (-not $env:OPENBLAS_DLL) { + Write-Error "OPENBLAS_DLL is not set (install-openblas-windows.ps1 must run before this step)" + exit 1 + } + if (-not (Test-Path $env:OPENBLAS_DLL)) { + Write-Error "openblas.dll not found at $env:OPENBLAS_DLL" + exit 1 + } + + $releaseDirs = @( + "src-tauri/target/release", + "src-tauri/target/x86_64-pc-windows-msvc/release" + ) + + foreach ($dir in $releaseDirs) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + Copy-Item $env:OPENBLAS_DLL (Join-Path $dir "openblas.dll") -Force + Write-Host "[ok] Copied openblas.dll to $dir" + } + + # ── Upload cargo build timings ─────────────────────────────────────────── + # The --timings flag produces an HTML report showing per-crate compile + # durations. Upload it so slow crates can be identified and targeted. + - name: Upload cargo build timings + if: always() + uses: actions/upload-artifact@v7 + with: + name: cargo-timings-windows + path: src-tauri/target/cargo-timings/ + retention-days: 30 + + # ── Log binary dependencies (diagnostic) ────────────────────────────── + # CRT DLLs were already located in the "Ensure VC++ CRT DLLs available" + # step before the build. This step just logs the dependency list. + - name: Log binary dependencies + shell: powershell + run: | + $exeCandidates = @( + "src-tauri/target/x86_64-pc-windows-msvc/release/skill.exe", + "src-tauri/target/release/skill.exe" + ) + + $exe = $null + foreach ($candidate in $exeCandidates) { + if (Test-Path $candidate) { + $exe = $candidate + break + } + } + + if (-not $exe) { + Write-Error "skill.exe not found. Checked: $(($exeCandidates -join ', '))" + exit 1 + } + + Write-Host "Binary dependencies:" + & dumpbin /dependents $exe + + Write-Host "" + Write-Host "CRT DLLs will be bundled from: $env:CRT_REDIST_DIR" + + # ── Package NSIS installer ─────────────────────────────────────────────── + # + # Runs the NSIS packaging script against the pre-built binary, then + # optionally signs the installer and creates updater artifacts. + - name: Package NSIS installer + shell: powershell + run: | + & powershell -ExecutionPolicy Bypass -File scripts/create-windows-nsis.ps1 + if ($LASTEXITCODE -ne 0) { + Write-Host "[error] create-windows-nsis.ps1 failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + + $bundleDirCandidates = @( + (Join-Path $PWD "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis"), + (Join-Path $PWD "src-tauri/target/release/bundle/nsis") + ) + + $bundleDir = $null + $installer = $null + foreach ($candidate in $bundleDirCandidates) { + if (-not (Test-Path $candidate)) { continue } + $candidateInstaller = Get-ChildItem -Path $candidate -File -Filter "*.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch "(?i)uninstall" } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($candidateInstaller) { + $bundleDir = $candidate + $installer = $candidateInstaller + break + } + } + + if (-not $installer) { + Write-Host "[error] NSIS installer .exe not found in expected bundle directories" + foreach ($candidate in $bundleDirCandidates) { + if (Test-Path $candidate) { + Write-Host "[diag] bundle dir snapshot: $candidate" + Get-ChildItem -Path $candidate -File -ErrorAction SilentlyContinue | + Select-Object Name, Length | + Format-Table -AutoSize | + Out-String | + Write-Host + } else { + Write-Host "[diag] bundle dir not found: $candidate" + } + } + exit 1 + } + + Write-Host "[ok] NSIS installer built: $($installer.FullName) ($([math]::Round($installer.Length / 1MB, 1)) MB)" + + # ── Sign installer + create updater artifacts ──────────────────────────── + # Skipped in dry-run mode (no signing secrets available). + - name: Sign installer + create updater artifacts + if: steps.version_meta.outputs.dry_run != 'true' + shell: powershell + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + $bundleDirCandidates = @( + (Join-Path $PWD "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis"), + (Join-Path $PWD "src-tauri/target/release/bundle/nsis") + ) + + $bundleDir = $null + $installer = $null + foreach ($candidate in $bundleDirCandidates) { + if (-not (Test-Path $candidate)) { continue } + $candidateInstaller = Get-ChildItem -Path $candidate -File -Filter "*.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch "(?i)uninstall" } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($candidateInstaller) { + $bundleDir = $candidate + $installer = $candidateInstaller + break + } + } + + if ($env:CERTIFICATE_THUMBPRINT) { + Write-Host "[ok] signing installer: $($installer.FullName)" + & signtool sign ` + /sha1 $env:CERTIFICATE_THUMBPRINT ` + /fd sha256 ` + /tr http://timestamp.digicert.com ` + /td sha256 ` + /v ` + $installer.FullName + if ($LASTEXITCODE -ne 0) { + Write-Host "[error] signtool failed while signing installer" + exit $LASTEXITCODE + } + } else { + Write-Host "[warn] CERTIFICATE_THUMBPRINT is not set; installer remains unsigned" + } + + $zipPath = Join-Path $bundleDir ("$($installer.BaseName).nsis.zip") + $sigPath = "$zipPath.sig" + + if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + if (Test-Path $sigPath) { Remove-Item $sigPath -Force } + + # Use Deflate (CompressionLevel.Optimal) for the updater zip. + # zip 4.6.1 now has flate2 + deflate64 enabled via the workspace + # dependency in Cargo.toml, so both method 8 (Deflate) and method + # 9 (Deflate64) are supported by tauri-plugin-updater at extract time. + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::Open( + $zipPath, + [System.IO.Compression.ZipArchiveMode]::Create + ) + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $zip, + $installer.FullName, + $installer.Name, + [System.IO.Compression.CompressionLevel]::Optimal + ) | Out-Null + $zip.Dispose() + Write-Host "[ok] created updater zip (Deflate): $zipPath" + + & npx tauri signer sign $zipPath + if ($LASTEXITCODE -ne 0) { + Write-Host "[error] tauri signer failed for updater zip" + exit $LASTEXITCODE + } + + if (-not (Test-Path $sigPath)) { + Write-Host "[error] expected updater signature not found: $sigPath" + exit 1 + } + + Write-Host "[ok] updater artifacts created (.nsis.zip + .sig)" + + # ── Locate build artifacts ──────────────────────────────────────────────── + - name: Collect artifact paths + if: steps.version_meta.outputs.dry_run != 'true' + id: artifacts + shell: bash + run: | + CANDIDATES=( + "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis" + "src-tauri/target/release/bundle/nsis" + ) + + BUNDLE_DIR="" + NSIS_EXE="" + NSIS_ZIP="" + NSIS_SIG="" + + for candidate in "${CANDIDATES[@]}"; do + [ -d "$candidate" ] || continue + + candidate_exe="$(find "$candidate" -maxdepth 1 -name "*.exe" ! -iname "*uninstall*" | head -1)" + candidate_zip="$(find "$candidate" -maxdepth 1 -name "*.nsis.zip" ! -name "*.sig" | head -1)" + candidate_sig="$(find "$candidate" -maxdepth 1 -name "*.nsis.zip.sig" | head -1)" + + if [ -n "$candidate_exe" ] && [ -n "$candidate_zip" ] && [ -n "$candidate_sig" ]; then + BUNDLE_DIR="$candidate" + NSIS_EXE="$candidate_exe" + NSIS_ZIP="$candidate_zip" + NSIS_SIG="$candidate_sig" + break + fi + done + + if [ -z "$NSIS_EXE" ] || [ -z "$NSIS_ZIP" ] || [ -z "$NSIS_SIG" ]; then + echo "::error::One or more expected artifacts are missing." + for candidate in "${CANDIDATES[@]}"; do + if [ -d "$candidate" ]; then + echo "[diag] listing $candidate" + find "$candidate" -maxdepth 1 -type f | sort || true + else + echo "[diag] bundle dir not found: $candidate" + fi + done + echo " EXE : ${NSIS_EXE:-(not found)}" + echo " ZIP : ${NSIS_ZIP:-(not found)}" + echo " SIG : ${NSIS_SIG:-(not found)}" + exit 1 + fi + + echo "nsis_exe=$NSIS_EXE" >> "$GITHUB_OUTPUT" + echo "nsis_zip=$NSIS_ZIP" >> "$GITHUB_OUTPUT" + echo "nsis_sig=$NSIS_SIG" >> "$GITHUB_OUTPUT" + echo "nsis_zip_name=$(basename "$NSIS_ZIP")" >> "$GITHUB_OUTPUT" + + echo "Artifacts found:" + echo " EXE : $NSIS_EXE ($(du -sh "$NSIS_EXE" | cut -f1))" + echo " ZIP : $NSIS_ZIP ($(du -sh "$NSIS_ZIP" | cut -f1))" + echo " SIG : $NSIS_SIG" + + # ── Create / update GitHub Release with Windows binaries ───────────────── + # + # softprops/action-gh-release creates the release if it doesn't exist yet, + # or adds files to it if it was already created by the macOS or Linux + # workflow. latest.json is handled in the next step. + # + # Windows assets uploaded here: + # *_x64-setup.exe — NSIS installer (distribute to users) + # *.nsis.zip — Tauri updater bundle + # *.nsis.zip.sig — Ed25519 signature for the updater + - name: Prepare changelog notes for release + if: steps.version_meta.outputs.dry_run != 'true' + shell: bash + run: | + TAG="${{ steps.version_meta.outputs.tag }}" + PREV_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" + RANGE="${PREV_TAG:+$PREV_TAG..}$TAG" + node scripts/ci.mjs prepare-changelog \ + "${{ steps.version_meta.outputs.version }}" release-notes.md "$RANGE" + + - name: Create / update GitHub Release + if: steps.version_meta.outputs.dry_run != 'true' + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.version_meta.outputs.tag }} + name: Skill v${{ steps.version_meta.outputs.version }} + generate_release_notes: false + body_path: release-notes.md + fail_on_unmatched_files: true + prerelease: ${{ steps.version_meta.outputs.prerelease == 'true' }} + files: | + ${{ steps.artifacts.outputs.nsis_exe }} + ${{ steps.artifacts.outputs.nsis_zip }} + ${{ steps.artifacts.outputs.nsis_sig }} + + # ── Update latest.json ──────────────────────────────────────────────────── + # + # Downloads the current latest.json from the release (created by whichever + # platform workflow ran first), merges the windows-x86_64 entry, then + # re-uploads. If no latest.json exists yet, creates one from scratch. + - name: Update latest.json + if: steps.version_meta.outputs.dry_run != 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: >- + node scripts/ci.mjs update-latest-json + --platform windows-x86_64 + --url "https://github.com/${{ github.repository }}/releases/download/${{ steps.version_meta.outputs.tag }}/${{ steps.artifacts.outputs.nsis_zip_name }}" + --sig-file "${{ steps.artifacts.outputs.nsis_sig }}" + --tag "${{ steps.version_meta.outputs.tag }}" + --version "${{ steps.version_meta.outputs.version }}" + --upload + --mirror-to-rc-latest + + # ── Upload CI artifacts (on-demand / dry-run builds only) ──────────────── + - name: Upload build artifacts + if: steps.version_meta.outputs.is_release != 'true' + uses: actions/upload-artifact@v7 + with: + name: NeuroSkill-Windows-${{ steps.version_meta.outputs.version }} + path: | + ${{ steps.artifacts.outputs.nsis_exe }} + ${{ steps.artifacts.outputs.nsis_zip }} + retention-days: 14 + + # ── Remove the certificate from the runner's cert store ────────────────── + - name: Remove signing certificate + if: always() && steps.version_meta.outputs.dry_run != 'true' && steps.signing_meta.outputs.have_windows_cert == 'true' + shell: powershell + run: | + if ($env:CERTIFICATE_THUMBPRINT) { + Get-ChildItem Cert:\CurrentUser\My\$env:CERTIFICATE_THUMBPRINT ` + -ErrorAction SilentlyContinue | + Remove-Item -Force + Write-Host "[ok] Certificate removed from store." + } + + # ── Discord notification ────────────────────────────────────────────────── + - name: Notify Discord of release + if: always() && steps.version_meta.outputs.is_release == 'true' + shell: bash + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: >- + node scripts/ci.mjs discord-notify + --status "${{ job.status }}" + --title "${{ job.status == 'success' && '🚀' || '💥' }} NeuroSkill™ v${{ steps.version_meta.outputs.version }} — Windows ${{ job.status == 'success' && 'Published' || 'Failed' }}" + --version "${{ steps.version_meta.outputs.version }}" + --tag "${{ steps.version_meta.outputs.tag }}" + --platform "Windows x86_64" + --release-url "${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ steps.version_meta.outputs.tag }}" + --run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a8bb9d614..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,378 +0,0 @@ -name: Release - -# Triggered by pushing a semver tag: git tag v1.2.3 && git push origin v1.2.3 -on: - push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - -# Write permission is required to create GitHub Releases and upload assets. -permissions: - contents: write - -jobs: - release: - name: Build & Release — aarch64-apple-darwin - runs-on: macos-26 - environment: Release - - steps: - # ── Checkout ────────────────────────────────────────────────────────────── - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 # full history so git tag --format can read the annotation - - # ── Verify tag matches tauri.conf.json version ──────────────────────────── - # Catches "forgot to bump the version" before wasting 20 min on a build. - - name: Verify version matches tag - run: | - TAG="${{ github.ref_name }}" - VERSION="${TAG#v}" # strip leading 'v' → 1.2.3 - CONF_VERSION=$( - grep '"version"' src-tauri/tauri.conf.json \ - | head -1 \ - | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' - ) - if [ "$VERSION" != "$CONF_VERSION" ]; then - echo "::error::Tag version ($VERSION) does not match" \ - "tauri.conf.json version ($CONF_VERSION)." - echo "::error::Bump the version in src-tauri/tauri.conf.json and" \ - "src-tauri/Cargo.toml, then re-tag." - exit 1 - fi - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - echo "TAG=$TAG" >> "$GITHUB_ENV" - echo "✓ Version verified: $VERSION" - - # ── Rust toolchain ──────────────────────────────────────────────────────── - - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin - - # ── Cargo cache ─────────────────────────────────────────────────────────── - # Swatinem/rust-cache is purpose-built for Rust: it strips test artifacts, - # stale deps, and other bloat before saving, keeping the cache lean. - - name: Cache Cargo registry + build artifacts - uses: Swatinem/rust-cache@v2 - with: - workspaces: src-tauri - - # ── Node.js ─────────────────────────────────────────────────────────────── - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install JS dependencies - run: npm ci - - # ── ImageMagick (needed by scripts/stamp-dmg-icon.sh) ──────────────────── - # sips, iconutil, and Python/AppKit ship with the macOS runner image. - # ImageMagick is NOT pre-installed on macos-26 (confirmed against the - # actions/runner-images macos-26-arm64-Readme.md manifest), so we install - # it via Homebrew when missing. The guard avoids a redundant install if - # a future runner image ever bundles it. - - name: Ensure ImageMagick is available - run: | - if command -v magick >/dev/null 2>&1; then - echo "ImageMagick already present: $(magick --version | head -1)" - else - echo "ImageMagick not found — installing via Homebrew…" - brew install imagemagick - echo "Installed: $(magick --version | head -1)" - fi - - # ── Pre-flight: verify every secret is set ─────────────────────────────── - # Catches missing / renamed secrets in seconds instead of after a 15-minute - # build. Each variable is checked for non-empty; none of the values are - # printed. - - name: Verify signing and notarization secrets are present - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - run: | - ok=true - for var in \ - APPLE_CERTIFICATE \ - APPLE_CERTIFICATE_PASSWORD \ - APPLE_SIGNING_IDENTITY \ - APPLE_ID \ - APPLE_PASSWORD \ - APPLE_TEAM_ID \ - TAURI_SIGNING_PRIVATE_KEY - do - if [ -z "${!var}" ]; then - echo "::error::Secret '$var' is empty or not set." - ok=false - fi - done - $ok || exit 1 - echo "✓ All signing and notarization secrets are present." - - # ── Pre-flight: validate Apple notarization credentials ─────────────────── - # xcrun notarytool history authenticates against Apple's API using the same - # credentials the build will use, but without submitting anything. - # A 401 here means the app-specific password is wrong or expired. - # Generate a fresh one at https://appleid.apple.com → App-Specific Passwords, - # then update the APPLE_PASSWORD secret in the repository environment. - - name: Validate Apple notarization credentials - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - echo "Checking notarization credentials …" - if ! xcrun notarytool history \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --output-format json 2>&1 | grep -q '"history"'; then - echo "::error::Apple notarization credentials are invalid." - echo "::error::Generate a new app-specific password at" - echo "::error:: https://appleid.apple.com → Sign-In and Security" - echo "::error:: → App-Specific Passwords → Generate" - echo "::error::Then update the APPLE_PASSWORD secret in:" - echo "::error:: GitHub → Settings → Environments → Release → Secrets" - exit 1 - fi - echo "✓ Notarization credentials are valid." - - # ── espeak-ng static library ────────────────────────────────────────────── - # Cache keyed on the build script itself; a script change busts the cache. - - name: Cache espeak-ng static lib - id: cache-espeak - uses: actions/cache@v4 - with: - path: src-tauri/espeak-static - key: macos-arm64-espeak-${{ hashFiles('scripts/build-espeak-static.sh') }} - - - name: Build espeak-ng static lib - if: steps.cache-espeak.outputs.cache-hit != 'true' - run: ESPEAK_ARCHS=arm64 bash scripts/build-espeak-static.sh - - # ── Apple Developer certificate → temporary keychain ───────────────────── - # - # Secrets required: - # APPLE_CERTIFICATE — base64-encoded .p12 (Developer ID Application cert) - # APPLE_CERTIFICATE_PASSWORD — password protecting the .p12 - # - # How to export the .p12: - # Keychain Access → right-click cert → Export → Personal Information Exchange - # Then: base64 -i cert.p12 | pbcopy (paste into the GitHub secret) - - name: Import Apple Developer certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" - KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" - # Persist to env so the cleanup step can always find the path. - echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" - echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV" - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - # Keep the keychain unlocked for the duration of the job (6 h max). - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - # Decode and import the certificate. - echo -n "$APPLE_CERTIFICATE" \ - | base64 --decode -o "$RUNNER_TEMP/cert.p12" - security import "$RUNNER_TEMP/cert.p12" \ - -k "$KEYCHAIN_PATH" \ - -P "$APPLE_CERTIFICATE_PASSWORD" \ - -T /usr/bin/codesign \ - -T /usr/bin/security - rm -f "$RUNNER_TEMP/cert.p12" - - # Allow codesign to use the key without a password prompt. - security set-key-partition-list \ - -S apple-tool:,apple: \ - -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - # Make this keychain searchable by codesign / Tauri. - security list-keychains -d user \ - -s "$KEYCHAIN_PATH" login.keychain - - # ── Tauri build — sign + notarize + create updater artifacts ───────────── - # - # Secrets required: - # APPLE_SIGNING_IDENTITY — e.g. "Developer ID Application: Jane (A1B2C3D4E5)" - # APPLE_ID — Apple ID email used with notarytool - # APPLE_PASSWORD — app-specific password (appleid.apple.com) - # APPLE_TEAM_ID — 10-character Team ID - # TAURI_SIGNING_PRIVATE_KEY — base64 Ed25519 private key (from generate-keys.py) - # TAURI_SIGNING_PRIVATE_KEY_PASSWORD — key password (empty string if none) - # - # What Tauri does automatically when these vars are set: - # 1. codesign --deep --options runtime --entitlements ./entitlements.plist - # 2. xcrun notarytool submit … --wait - # 3. xcrun stapler staple - # 4. Creates .tar.gz + .tar.gz.sig (updater artifacts) - # 5. Packages the signed + notarized .dmg - - name: Build Tauri app (sign + notarize) - env: - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - ESPEAK_LIB_DIR: ${{ github.workspace }}/src-tauri/espeak-static/lib - run: | - npx tauri build \ - --target aarch64-apple-darwin \ - --bundles app,dmg - - # ── Always delete the temporary keychain ────────────────────────────────── - - name: Delete temporary keychain - if: always() - run: security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true - - # ── Locate build artifacts ──────────────────────────────────────────────── - - name: Collect artifact paths - id: artifacts - run: | - BUNDLE_DIR="src-tauri/target/aarch64-apple-darwin/release/bundle" - - APP_TAR="$(find "$BUNDLE_DIR/macos" \ - -name "*.app.tar.gz" ! -name "*.sig" \ - -maxdepth 1 | head -1)" - APP_SIG="$(find "$BUNDLE_DIR/macos" \ - -name "*.app.tar.gz.sig" \ - -maxdepth 1 | head -1)" - DMG="$(find "$BUNDLE_DIR/dmg" \ - -name "*.dmg" \ - -maxdepth 1 | head -1)" - - if [ -z "$APP_TAR" ] || [ -z "$APP_SIG" ] || [ -z "$DMG" ]; then - echo "::error::One or more expected artifacts are missing." - echo " APP_TAR : $APP_TAR" - echo " APP_SIG : $APP_SIG" - echo " DMG : $DMG" - exit 1 - fi - - echo "app_tar=$APP_TAR" >> "$GITHUB_OUTPUT" - echo "app_sig=$APP_SIG" >> "$GITHUB_OUTPUT" - echo "dmg=$DMG" >> "$GITHUB_OUTPUT" - echo "app_tar_name=$(basename "$APP_TAR")" >> "$GITHUB_OUTPUT" - echo "dmg_name=$(basename "$DMG")" >> "$GITHUB_OUTPUT" - - echo "Artifacts found:" - echo " TAR : $APP_TAR ($(du -sh "$APP_TAR" | cut -f1))" - echo " SIG : $APP_SIG" - echo " DMG : $DMG ($(du -sh "$DMG" | cut -f1))" - - # ── Stamp version badge onto the DMG icon ──────────────────────────────── - # Composites "v" over the app icon and attaches the result as the - # DMG file's custom Finder icon via NSWorkspace so every build is instantly - # distinguishable in Finder's icon / gallery view without reading filenames. - - name: Stamp version badge onto DMG icon - run: | - bash scripts/stamp-dmg-icon.sh \ - "${{ steps.artifacts.outputs.dmg }}" \ - "${{ env.VERSION }}" \ - "src-tauri/icons/icon.png" - - # ── Generate latest.json ────────────────────────────────────────────────── - # - # latest.json is uploaded as a release asset so it is reachable at: - # https://github.com/NeuroSkill-com/skill/releases/latest/download/latest.json - # - # Point tauri.conf.json → plugins.updater.endpoints at that URL so the - # built-in updater can find new versions without any server-side logic: - # - # "endpoints": [ - # "https://github.com/NeuroSkill-com/skill/releases/latest/download/latest.json" - # ] - # - # The Tauri v2 updater JSON schema: - # https://tauri.app/plugin/updater/#update-server-json-format - - name: Generate latest.json - env: - GH_REPO: ${{ github.repository }} - TAG: ${{ env.TAG }} - VERSION: ${{ env.VERSION }} - APP_TAR_NAME: ${{ steps.artifacts.outputs.app_tar_name }} - APP_SIG_PATH: ${{ steps.artifacts.outputs.app_sig }} - run: | - python3 - <<'PYEOF' - import json, os, subprocess, datetime, sys - - version = os.environ["VERSION"] - tag = os.environ["TAG"] - repo = os.environ["GH_REPO"] - app_tar_name = os.environ["APP_TAR_NAME"] - sig_path = os.environ["APP_SIG_PATH"] - - with open(sig_path) as fh: - signature = fh.read().strip() - - pub_date = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") - - # Use the git tag annotation as release notes; fall back gracefully. - try: - notes = subprocess.check_output( - ["git", "tag", "-l", "--format=%(contents)", tag], - text=True - ).strip() - except Exception: - notes = "" - if not notes: - notes = f"NeuroSkill™ v{version}" - - download_url = ( - f"https://github.com/{repo}/releases/download/{tag}/{app_tar_name}" - ) - - manifest = { - "version": version, - "notes": notes, - "pub_date": pub_date, - "platforms": { - "darwin-aarch64": { - "url": download_url, - "signature": signature, - } - }, - } - - with open("latest.json", "w") as fh: - json.dump(manifest, fh, indent=2) - fh.write("\n") - - print("Generated latest.json:") - print(json.dumps(manifest, indent=2)) - PYEOF - - # ── Create GitHub Release + upload all assets ───────────────────────────── - # - # Uploaded assets for this release: - # skill__aarch64.dmg — installer (distribute to users) - # skill__aarch64.app.tar.gz — updater bundle (consumed by Tauri updater) - # skill__aarch64.app.tar.gz.sig — Ed25519 signature for the updater bundle - # latest.json — updater manifest (static endpoint for the updater) - # - # The `latest.json` asset is accessible at the stable URL: - # https://github.com/NeuroSkill-com/skill/releases/latest/download/latest.json - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ env.TAG }} - name: Skill v${{ env.VERSION }} - generate_release_notes: true - fail_on_unmatched_files: true - files: | - ${{ steps.artifacts.outputs.dmg }} - ${{ steps.artifacts.outputs.app_tar }} - ${{ steps.artifacts.outputs.app_sig }} - latest.json diff --git a/.gitignore b/.gitignore index 25578ecee..ae20d303e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ node_modules .env .env.* !.env.example +!.envrc +rlx.path vite.config.js.timestamp-* vite.config.ts.timestamp-* @@ -18,5 +20,32 @@ src-tauri/keys/.env.keys **/*.gguf **/*.safetensors +# Rust build artifacts (main crate + workspace crates) +**/target/ + rust_out -TODO.md \ No newline at end of file +TODO.md +AGENTS.md +# docs/screenshots/gifs/*.gif + +.gitrub_activity +# Rust lock files — Cargo.lock is committed (application, not library) +test-results/ +src-tauri/binaries/ +errors/ +logs/ +output/ + +# Bundled TTS engine weights — staged by scripts/bundle-tts-weights.sh before packaging. +# inflect-nano is committed (declared in tauri.conf.json bundle.resources so CI can build the app); +# other engine bundles (e.g. tiny-tts) stay local-only. +src-tauri/resources/tts/* +!src-tauri/resources/tts/inflect-nano/ +# Re-include the bundle contents too, overriding the global **/*.safetensors ignore above. +!src-tauri/resources/tts/inflect-nano/** + +# OCR weights bundled into skill-screenshots via include_bytes! (rlx-ocr, converted +# offline from the ocrs rten checkpoints) so CI can build the app offline. +# Override the global **/*.safetensors ignore above. +!crates/skill-screenshots/assets/ocr-detection.safetensors +!crates/skill-screenshots/assets/ocr-recognition.safetensors diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..85db65335 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,15 @@ +[submodule "skills"] + path = skills + url = https://github.com/NeuroSkill-com/skills.git +[submodule "neuroloop"] + path = neuroloop + url = https://github.com/NeuroSkill-com/neuroloop +[submodule "neuroskill"] + path = neuroskill + url = https://github.com/NeuroSkill-com/neuroskill +[submodule "extensions/vscode"] + path = extensions/vscode + url = https://github.com/NeuroSkill-com/vscode-neuroskill.git +[submodule "extensions/browser"] + path = extensions/browser + url = https://github.com/NeuroSkill-com/browser-extension.git diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 61343e9b8..76ac8ff94 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ "recommendations": [ "svelte.svelte-vscode", "tauri-apps.tauri-vscode", - "rust-lang.rust-analyzer" + "rust-lang.rust-analyzer", + "neuroskill.neuroskill" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a8158cc..adea0130b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,87 +1,5723 @@ # Changelog All notable changes to NeuroSkill™ are documented here. +Pending changes live as fragments in [`changes/unreleased/`](changes/unreleased/). +Past releases are archived in [`changes/releases/`](changes/releases/). + +--- + +## [Unreleased] + +## [0.0.129] — 2026-04-24 + +### Features + +- **Grayscale display mode**: optional system-wide grayscale display that activates and deactivates in sync with Do Not Disturb. Reduces visual distraction during deep work. macOS only, default off — the default focus action remains Do Not Disturb only. + +### i18n + +- **Grayscale translations**: added `dnd.grayscale` and `dnd.grayscaleDesc` keys to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). + +### Dependencies + +- **Fix rand 0.7.3 vulnerability**: vendored `phf_generator 0.8.0` locally with `rand` bumped from 0.7 to 0.8, eliminating the vulnerable `rand 0.7.3` transitive dependency pulled in by `selectors 0.24 → phf_codegen 0.8`. +- **Remove atty**: migrated `iroh_test_client` from `structopt` (clap v2) to `clap v4` derive, dropping the unmaintained `atty` crate. +- **Dismiss stale Dependabot alerts**: dismissed alerts for `crossbeam-deque`, `crossbeam-utils`, `crossbeam-queue`, `memoffset`, and `glib` — all already at patched versions or fixed in fork. + +- **Update kittentts to 0.4.0**: bumped `kittentts` from 0.3.0 to 0.4.0 in both `skill-tts` and `src-tauri`. + +- **Update llama-cpp-4 to 0.2.50**: bumped `llama-cpp-4` and `llama-cpp-sys-4` from 0.2.47 to 0.2.50, picking up upstream llama.cpp fixes including `common_*` symbol renames and `llama-common-base` static library linking. + +- **Add grayscale crate**: added `grayscale 0.0.1` as a macOS-only dependency in `skill-data`. + +## [0.0.128] — 2026-04-23 + +### Features + +- llmfit +- glib +- notify +- linter +- updTed settings and engine +- muda +- fixed glib with a patch +- refactored UI +- Added unsloth/Qwen3.6-27B-GGUF + +## [0.0.127] — 2026-04-22 + +### Features + +- Add ANT Neuro eego amplifier family support via the `antneuro` crate (native USB backend, no vendor SDK required) +- Supported models: eego 8, eego 24, eego 64, eego mylab, eego sport, eego rt, eego hub +- Auto-detect channel count and sample rate from the amplifier on first data block +- Configurable cap layout via Device API settings (`antneuro_cap`: auto, 10-20, 10-10, or explicit channel count) +- Configurable sample rate via Device API settings (`antneuro_sample_rate`: 500, 512, 1000, 2048, etc.) +- Waveguard electrode name mappings for 8, 21, 24, 25, 32, and 64-channel cap configurations +- USB scanner auto-detects connected eego amplifiers every other tick +- Device images and company logo for all 7 amplifier models in the Supported Devices catalog + +### Bugfixes + +- Fix PPG and IMU sample counts not being tracked in daemon status (ppg_sample_count/imu_sample_count always stayed at 0) +- Fix `cmd_status` device block missing `firmware`, `ppg_samples`, and `imu_samples` fields (neuroskill CLI showed nulls) +- Fix `format_status_as_text` reading stale key names (`sample_count` instead of `eeg_samples`) + +### UI + +- Improved supported devices card grid: wider columns, taller images with `object-contain`, text wraps instead of truncating + +## [0.0.126] — 2026-04-20 + +### Features + +- daemon restart on launch + +## [0.0.125] — 2026-04-20 + +### Features + +- fixed auto connect + +## [0.0.124] — 2026-04-20 + +### Features + +- fixed daemon invoke +- linter +- fixed calibration +- fixed `set default` +- better connect +- fixed connectivity + +## [0.0.123] — 2026-04-19 + +### Features + +- Add daemon calibration session HTTP routes: `POST /v1/calibration/session/start`, `POST /v1/calibration/session/cancel`, `GET /v1/calibration/session/status`. +- Add `POST /v1/calibration/record-completed` and `GET /v1/calibration/active-profile` daemon routes. +- Daemon broadcasts `calibration-tts` events with spoken text before each phase, giving the frontend a 4-second window to play TTS audio before the countdown begins. + +- Auto-install daemon as persistent OS service on first app launch. The Tauri app calls the daemon's `/service/install` endpoint after startup, registering a LaunchAgent (macOS), systemd user unit (Linux), or Windows service. The daemon now persists across app restarts and reboots. +- Add `--uninstall` CLI flag to skill-daemon for clean service removal. + +- **LLM catalog**: Added Qwen3.6 35B-A3B (MoE), MiniMax M2.7, and GLM 5.1 models to the local LLM catalog. +- **HuggingFace GGUF search**: New UI section in Settings → LLM to search HuggingFace Hub for GGUF models. Browse repos by downloads/likes, expand to see individual quant files with sizes, and add models to the catalog with one click (optionally starting download immediately). Each repo shows a collapsible README (rendered as markdown with proper table and heading styling, YAML frontmatter stripped) and a direct link to the HuggingFace page. Two new daemon endpoints: `GET /llm/catalog/search` and `GET /llm/catalog/search/files` (the latter also returns the repo README, capped at 32 KB). Fully localized (9 languages). + +- **Interactive search: AI summary with streaming.** "Generate" button in the Insights panel sends search context (labels, EEG metrics, sessions, screenshots) to the local LLM via `chat_completions_ipc` with a channel callback. Response streams token-by-token with live markdown rendering via `MarkdownRenderer`. Thinking blocks are shown in a collapsible `
` element. + +- **Interactive search: rich AI prompt with full EEG context.** The LLM prompt now includes: + - Time range and epoch count + - Labels with timestamps and similarity distances + - EEG epoch metrics: engagement, relaxation, SNR, α/β/θ band powers, heart rate, relevance score, session ID + - On-demand timeseries fetch when graph nodes lack stored metrics (computes averages from raw data) + - Session metrics with stddev, best-session flag + - Screenshot context: app name, window title, timestamp, OCR similarity + - Fallback session derivation from node session_ids when backend sessions are empty + +- **Interactive search: prompt visibility.** Collapsible "Prompt" section shown immediately when "Generate" is clicked (open by default during generation, collapses after). Shows the exact data sent to the LLM in monospace font. + +- **Interactive search: LLM auto-start.** When the LLM is not running, shows "Start LLM and try again" button that calls `start_llm_server`, waits for initialization, and retries the summary automatically. + +- **Interactive search: Continue in Chat.** "Continue in Chat →" button after the AI summary creates a new chat session named "Search: {query}", saves the prompt and response as messages, and opens the chat window. Users can ask follow-up questions about their search results. + +- **Interactive search: auto-save to chat history.** Every AI summary is automatically saved as a chat session, making all LLM conversations from search discoverable in the chat history regardless of whether the user clicks "Continue in Chat". + +- **Interactive search: insights panel.** Collapsible "Insights & Patterns" card with: + - Optimal conditions: auto-detected peak engagement hour and best app + - App × Engagement correlation: bar chart showing avg engagement per app (uses `window_title` fallback) + - Hour-of-day engagement pattern: bar chart across 24 hours + - AI summary with streaming, thinking blocks, markdown rendering, and chat integration + +- **Interactive search: bookmark/findings system.** Star button in node detail panel saves nodes to localStorage (max 50). Saved findings appear in the empty state with quick re-search and delete controls. + +- **Interactive search: graph color mode switcher.** Dropdown to recolor EEG nodes by timestamp (turbo), engagement level, SNR quality, or session (hash-based). Graph reactivity fixed with explicit dependency tracking. + +- **Interactive search: timeline scrubber.** Horizontal SVG timeline below the graph showing all timestamped nodes as color-coded clickable dots. Clicking selects the node in the detail panel. + +- **Interactive search: EEG sparkline.** "Load EEG bands ±60s" button on EEG point nodes fetches actual timeseries data and renders α (blue), β (amber), θ (green) band power chart with a red marker at the epoch timestamp. + +- **Interactive search: screenshot preview.** Inline screenshot thumbnail in the detail panel when a screenshot node is selected. + +- **Interactive search: compare mode.** Select two EEG points for side-by-side metrics comparison with green/red diff highlighting and time gap display. + +- **Interactive search: node detail panel improvements.** Moved to a separate card below the graph with colored left border, generous spacing, text-sm font sizes, responsive metrics grid, breadcrumb trail, and "More like this" / bookmark buttons. + +- **Interactive search: 3D graph enhancements.** Double-click zoom with smooth camera tween, minimap, node kind filtering, subtle grid floor, reset view button, enhanced tooltips with metrics/session/relevance. + +- Add device filter to EEG embedding search. A dropdown in the search controls lets users filter by device (e.g. MuseS-F921, AttentivU-053) or search across all devices. Backed by `GET /v1/search/devices` and `device_name` parameter on the streaming search endpoint. + +- **Per-epoch EEG metrics in AI summary prompt.** The interactive search AI summary now includes per-epoch metrics (engagement, relaxation, SNR, α/β/θ band powers, FAA, mood, TAR, meditation, cognitive load, drowsiness, heart rate) instead of just "(no EEG metrics stored)". Each 5-second epoch shows its own brain state data. + +- **On-the-fly CSV metrics fallback.** When embeddings lack `metrics_json`, the interactive search now lazily loads session `_metrics.csv` files and matches epochs by timestamp (±3s tolerance) to populate metrics from CSV data. This is transparent to the user — metrics appear without needing a backfill. + +- **Background metrics backfill from CSV.** New `POST /v1/analysis/backfill-metrics` endpoint patches `metrics_json` in the SQLite embeddings table for rows where it is NULL, matching against `_metrics.csv` by timestamp. Fires automatically on search page load (fire-and-forget). After first run, all future searches find metrics directly in the DB. + +- **Recomputation of derived EEG metrics.** When `meditation`, `cognitive_load`, `drowsiness`, or `stress_index` are null in stored `metrics_json` but band powers (α, β, θ, δ) are present, they are recomputed on-the-fly using the same formulas as the live pipeline. + +- **Shared EEG score formulas (`skill-data/eeg_scores.rs`).** New module with pure-math functions for computing derived EEG metrics from averaged band powers. + +- **Interactive search: EEG metrics on graph nodes.** Each EEG epoch node now carries its full metrics (engagement, relaxation, SNR, alpha/beta/theta, FAA, heart rate, etc.) via the `NeighborMetrics` struct, visible in the tooltip and detail panel. + +- **Interactive search: session grouping & cross-session comparison.** Every node gets a `session_id` derived from its timestamp. The results include a session summary with averaged metrics, stddev, min/max engagement/SNR, duration, and band power ratios. The best session (highest avg engagement) is auto-flagged with a ★ marker. + +- **Interactive search: cross-session trend chart.** SVG sparkline rendered above the session summary, showing engagement trend across sessions. Best session dot highlighted in green. + +- **Interactive search: relevance scoring.** Each EEG node receives a composite `relevance_score` combining text similarity (50%), temporal distance (30%), and engagement (20%). Nodes are optionally sortable by relevance in the UI and rendered larger in the 3D graph for high-relevance matches. + +- **Interactive search: SNR quality filter.** New `snrPositiveOnly` toggle (step 6) excludes EEG epochs with non-positive SNR (bad signal quality). Backed by server-side filtering before graph construction. + +- **Interactive search: device filter.** Device dropdown (step 7) in the interactive pipeline, filtering EEG epochs by device name at the SQL query level via `get_session_timeseries_filtered()`. + +- **Interactive search: date-range filter with presets.** Date-range inputs (step 8) constrain results to a time window. Quick preset buttons (24h, 7d, 30d) with a clear button. + +- **Interactive search: EEG epoch ranking.** Rank-by dropdown (step 9) sorts EEG epochs by engagement, SNR, or relaxation before selecting top-k, surfacing the most interesting epochs first. + +- **Interactive search: collapsible advanced filters.** Steps 6–9 are grouped behind an "Advanced filters" toggle to keep the default view clean. An amber dot indicator shows when any filter is active. + +- **Interactive search: performance timing & system load.** Each search returns `embed_ms`, `graph_ms`, `total_ms` timing plus CPU usage and memory stats via `sysinfo`. Displayed in a compact perf bar. + +- **Interactive search: search result caching.** LRU-style cache (8 entries, 5-minute TTL) in the daemon. Repeated searches with identical parameters return instantly without re-embedding. + +- **Interactive search: CSV export.** "Export CSV" button in the sessions summary panel downloads a CSV with all session metrics. + +- **Interactive search: search history.** Recent queries persisted in localStorage (max 10). Displayed as clickable chips in the empty state for quick re-runs. ArrowUp in empty textarea recalls the last query. + +- **Interactive search: pipeline settings persistence.** All pipeline parameters (kText, kEeg, kLabels, reachMinutes, SNR, rankBy, advanced toggle) saved to localStorage and restored on page load. + +- **Interactive search: 3D graph improvements.** + - **Double-click to zoom:** smoothly flies the camera to the selected node with easeInOutQuad tweening. + - **Minimap:** 80×80px SVG overlay in the bottom-right showing all node positions projected to 2D. + - **Node kind filtering:** toggle checkboxes to hide/show EEG, Labels, and Screenshot nodes. + - **Color mode switcher:** dropdown to recolor EEG nodes by timestamp, engagement, SNR, or session. + - **Node sizing by relevance:** higher-relevance EEG nodes render 1.0–1.3× larger. + - **Enhanced tooltips:** show relevance score, session ID, and full EEG metrics summary on hover. + - **Edge kind labels:** selected node tooltip shows connected edge types (→ text_sim, ← eeg_bridge). + - **Subtle grid floor:** transparent grid pattern at y=-15, adapts to dark/light theme. + - **Reset view button:** "⌂ Reset" button + click-empty-space to fly camera back to default position. + +- **Interactive search: node detail panel.** + - Separate card below the graph with generous spacing and readable typography. + - Colored left border matching node kind. + - Breadcrumb trail showing the full path from query → text_label → eeg_point → found_label. + - "More like this" button to re-search using the selected node's text. + - Bookmark button to save interesting nodes as findings. + - EEG sparkline: "Load EEG bands ±60s" fetches actual timeseries and renders α/β/θ band chart. + - Screenshot preview: inline thumbnail for screenshot nodes. + - Compare mode: select two EEG points for side-by-side metrics comparison with diff highlighting. + +- **Interactive search: timeline scrubber.** Horizontal SVG timeline below the graph showing all timestamped nodes as color-coded dots. Click any dot to select that node. + +- **Interactive search: insights & patterns panel.** + - Auto-computed activity-engagement correlation: groups screenshots by app and shows avg engagement per app as a bar chart. + - Hour-of-day engagement pattern: bar chart showing which hours have highest engagement. + - Optimal conditions report: identifies peak engagement time and best app. + - AI summary: sends search context to the local LLM for a natural-language analysis of patterns and recommendations. + - Bookmark/findings system: save interesting nodes to localStorage, displayed in the empty state for future reference. + +- **Interactive search: loading skeleton.** Graph-shaped SVG skeleton with animated nodes and edges replaces the simple spinner during search. + +- **Interactive search: animated session bars.** Engagement and SNR progress bars in the session summary have smooth CSS transitions on load. + +- **Interactive search: EEG epoch deduplication.** `seen_eeg_ts` HashSet prevents duplicate EEG nodes when multiple text labels have overlapping time windows. + +- **Interactive search: parallel compare_search.** Both range queries in `/search/compare` now run concurrently via `tokio::join!`. + +- **Interactive search: `get_session_timeseries_filtered()`.** New function in `skill-history` that filters timeseries by device name at the SQL level, used by the interactive search device filter. + +- Add text embedding model selection. Settings → Embeddings now shows 16 fastembed models (nomic, BGE, MiniLM, E5, MxBAI, GTE) with dimensions and descriptions. Selecting a model downloads weights and hot-swaps the ONNX runtime in the daemon. Persisted to settings and loaded on startup. Backed by `GET/POST /v1/models/text-embedding` daemon API. Unknown model codes are rejected. + +### Bugfixes + +- Fix calibration data not being recorded because `record_calibration_completed` wrote to stale local state instead of the daemon. +- Fix `list_calibration_profiles` returning default profiles instead of daemon-authoritative data. + +- Fix prebuilt llama archives missing `libmtmd` (multimodal library). The collect step in the prebuilt CI workflow now includes `libmtmd*` alongside `libllama*`/`libggml*`/`libcommon*`. +- Bump llama-cpp-4 from 0.2.45 to 0.2.46. +- Fix double daemon spawn on service install. The installer now checks `/service/status` before calling `/service/install`, and `install_launchagent` skips if the plist already exists with a matching binary path. +- Fix LaunchAgent label mismatch. Update hooks (`pre-update.cjs`, `post-update.cjs`) and uninstall script now use `com.skill.daemon` instead of stale `com.neuroskill.skill-daemon`. +- Remove stale `com.neuroskill.skill-daemon.plist` from app bundle Resources. +- Fix daemon logs written to `/tmp/` (wiped on reboot). LaunchAgent now logs to `~/Library/Logs/NeuroSkill/`. +- Add rollback binary as last-resort candidate in daemon path resolver. + +- Fix EXG reembedding: resolve GPU f16 TypeMismatch bug by using f32 GPU encoder for batch reembed, fix per-segment channel metadata for mixed-device sessions, require full 5s epoch extraction, skip channel counts with consecutive failures, and process most recent days first. +- Fix reembed progress events not reaching UI (Tauri `listen` → daemon WebSocket `onDaemonEvent`), fix `"day"` → `"date"` field in progress payload, and emit `loading_encoder`/`scanning` status immediately for responsive feedback. +- Rebuild label EEG HNSW index after manual and idle reembed so interactive search can bridge text labels to EEG epochs. +- Add 32 unit tests for reembed edge cases: mixed devices, relative timestamps, partial rows, empty files, boundary extraction, and channel count handling. + +- Fix daemon binary missing from DMG when prebuilt llama retry triggers on CI. The failed link left partial build state that cargo considered "fresh", so the retry silently skipped rebuilding the daemon. + +- **Fixed EEG metrics not appearing in search results.** The `MetricsBlob` deserializer in `skill-history/cache.rs` used `#[serde(default)]` on `f64` fields, which caused the entire JSON deserialization to fail silently when any field was `null` (e.g., `cognitive_load: null`, `drowsiness: null`). All metrics defaulted to zero, causing every epoch to show "(no EEG metrics stored)" in the AI summary prompt. Fixed by adding a `null_as_zero` custom deserializer that treats JSON `null` as `0.0` without failing the parse. + +- **Fixed search cache serving stale results after daemon restart.** Added `cache_clear()` to the search module and call it after metrics backfill completes, ensuring enriched metrics appear immediately. + +### Refactor + +- Move calibration timing loop from frontend to daemon. The daemon now drives all calibration phases (action countdowns, breaks, label submission) via a session runner with wall-clock-aligned timing. The frontend subscribes to WebSocket events for UI updates. +- Route all calibration commands through daemon HTTP endpoints. Profile CRUD, active profile selection, and calibration completion recording are now daemon-authoritative — the Tauri app is a thin proxy. +- Remove stale calibration state from Tauri `AppState`. Calibration profiles and active profile ID are no longer cached locally, preventing the save overlay from clobbering daemon-written timestamps. +- Delete `calibration_service.rs` — inline daemon HTTP calls directly in `window_cmds.rs`. +- Migrate onboarding calibration wizard to use daemon-driven sessions instead of a local timing loop. +- Remove unused `emit_calibration_event` Tauri command. + +- Make `ensure_daemon_running` non-blocking. Window now appears immediately while daemon connection is established on a background thread. +- Share a single `ureq::Agent` via `OnceLock` instead of creating one per HTTP call. + +### Build + +- Fix macOS CI smoke test checking wrong path for daemon binary (`Contents/MacOS/skill-daemon` → `Contents/MacOS/skill-daemon.app/Contents/MacOS/skill-daemon`). +- Fix Linux CI Vulkan SDK cache restoring system library paths with permission errors. Vulkan SDK is now installed via `cache-apt-pkgs-action` alongside other system dependencies. +- Fix Linux CI daemon link failure: add `cargo:rustc-link-lib=vulkan` and openblas search paths in daemon `build.rs` for prebuilt llama archives. +- Fix Linux CI Discord notification firing on manual dispatch builds. +- Remove duplicate unguarded portable tarball upload step from Linux release workflow. +- Exclude E2E test files from `vitest related` in pre-push hook. +- Add all calibration commands to `check-daemon-invokes.js` validation script. + +- Pin prebuilt llama download to specific tag (`0.2.46`) in `ci.mjs` instead of floating `latest`. +- Add explicit error and exit on daemon binary missing after prebuilt retry in release workflow. +- Add smoke-test step in release workflow: `codesign --verify`, architecture check with `file`/`lipo`. +- Add `scripts/test-daemon-e2e.sh` — 19 end-to-end tests covering fresh install, update hooks, uninstall, degraded states, edge cases, and connection reuse. + +- Add `mtmd` (multimodal) to llama-cpp-4 target-specific dependency features in skill-llm, ensuring prebuilt archives include multimodal symbols. +- Copy Frameworks directory (dynamic llama dylibs) into daemon `.app` bundle during assembly. +- Fail app bundle assembly if daemon binary is missing instead of silently continuing. +- Clean daemon build artifacts during prebuilt llama retry to force a full re-link. + +- Add `embed-zuna-gpu` (f32) feature to `embed-exg` for reliable GPU batch reembed. +- Fix all clippy warnings (dead code, large enum variant, too-many-arguments). +- Ignore Node.js DeprecationWarning in bump script warning detection. + +### UI + +- Add embedding health banner to search UI. Shows color-coded progress bars for EXG epochs, screenshots, and label EEG index when any modality is incomplete. Includes inline "Rebuild EEG index" button when the label-to-EEG bridge is empty. + +- Move Embeddings tab directly below EXG in the settings sidebar. + +### i18n + +- Added 80+ translation keys across all 9 locales (en, de, es, fr, he, ja, ko, uk, zh) for AI summary, insights, bookmarks, color modes, timeline, compare, LLM controls, and chat integration. + +- Added 60+ new translation keys across all 9 locales (en, de, es, fr, he, ja, ko, uk, zh) covering all new search features: filters, insights, timeline, compare, bookmarks, color modes, and performance stats. + +### Dependencies + +- Bump llama-cpp-4 / llama-cpp-sys-4 from 0.2.44 to 0.2.45. + +- Bump zuna-rs to 0.1.3 (fixes burn f16→f32 tensor extraction bug). + +## [0.0.122] — 2026-04-16 + +### Features + +- Add recent/frequent command ranking to Cmd-K. Commands you use most often and most recently appear at the top in a "Recent" section, with frequency and recency boosts applied to search scoring. +- Add typo tolerance to Cmd-K search. Misspellings like "drak mode" or "calbriation" now find the right result using Damerau-Levenshtein edit distance as a fallback when fuzzy matching fails. +- Add contextual boosting to Cmd-K. Device, EEG, Calibration, and LSL commands are ranked higher when a device is connected; Devices settings are boosted when no device is connected. +- Add keyboard-driven toggles in Cmd-K. Toggleable settings (dark mode, high contrast) show an ON/OFF indicator and can be flipped with the Tab key without closing the palette. +- Add prefix-based command filtering to Cmd-K. Type `>` to show only system commands or `@` to show only settings. +- Add semantic search fallback to Cmd-K via fastembed embeddings. When fuzzy results are sparse and the query is 6+ characters, a debounced request to the daemon's `/v1/search/commands` endpoint returns semantically similar commands in a "Suggested" section. Handles natural language queries like "make text bigger" or "reduce eye strain". +- Add toggle dark/light mode command to the Cmd-K palette. + +- Add deep-link search for individual settings in the Command Palette (Cmd-K). 154 settings across all 20 tabs are now searchable by name, description, and synonyms — typing "DND", "dark mode", "GPU", "OCR", etc. instantly finds the right setting. +- Auto-generate per-locale search indexes from i18n translation files and Tab components. Indexes are built for all 9 languages (EN, DE, ES, FR, HE, JA, KO, UK, ZH) and stay in sync automatically via a Vite plugin that watches for changes during development. +- Add synonym expansion to Cmd-K fuzzy search. Common abbreviations like DND, BT, GPU, OCR, LLM, and 30+ others are expanded before matching so users can search using shorthand. +- Add Command Palette button to the title bar of every window. Clicking the command-key icon opens Cmd-K, matching the keyboard shortcut behavior. +- Selecting a setting in Cmd-K now opens the Settings window, switches to the correct tab, scrolls to the exact setting, and flashes a blue highlight to draw attention to it. +- Wire settings index generation into `npm run dev`, `npm run build`, and `npm run bump` so indexes are always up to date before commits and builds. +- Add Chat and Compare shortcuts to the Shortcuts settings tab, making all 11 global shortcuts user-configurable. The Compare shortcut (previously hardcoded to Cmd+Shift+M) is now customizable like all others. +- Tray menu now reflects user-customized shortcuts for all actions including Chat and Compare. +- The keyboard shortcuts overlay (? key) now shows all 11 global shortcuts, matching the Shortcuts settings tab and tray menu. +- Global keyboard shortcuts no longer steal keystrokes from other apps. Shortcuts are now only registered when a Skill window is focused and unregistered when all windows lose focus, so the focused app always gets the keystroke first. The "Open NeuroSkill" shortcut (Cmd+Shift+O) remains always-on so users can bring the app to the foreground from anywhere. + +- **Session sidecar `device_kind`**: JSON sidecars now include `device_kind` (e.g. "muse", "awear", "openbci") alongside `device_name` and `channel_names`. Enables reliable device identification during reembedding when a day directory contains sessions from different devices. Backward compatible — old sidecars without this field are handled gracefully. + +- **Embedding coverage dashboard** in Settings → EXG → Re-embed. Shows a color-coded coverage bar (green ≥95%, amber ≥50%, red <50%), total/embedded/missing epoch counts, and estimated time remaining based on current embed speed. +- **Per-day breakdown table** in the re-embed section. Expandable table showing each day's total epochs, embedded count, missing count, and a mini coverage bar — makes it easy to identify which days have gaps. +- **Idle reembed status indicator**. When background embedding is enabled, the UI now shows whether it's actively processing (with day + progress), or waiting for the idle timeout to elapse, instead of running silently. +- **Re-embed progress improvements**. Progress bar now shows indeterminate animation during encoder loading, percentage + ETA during processing, amber bar when paused (device reconnected), red bar with error detail on failure. +- **EEG embedding coverage in Search page**. Corpus stats banner and empty-state panel now show epoch embedding coverage (e.g. "12,400/14,200 (87%)") with color coding, so you know at a glance whether your data is fully searchable. + +- **Multi-modal search**: Interactive search now returns screenshots (proximity + OCR text similarity) and EEG epochs alongside text labels. New `kScreenshots`, `kLabels`, and `reachMinutes` parameters control result depth. +- **Search corpus stats**: New `GET /search/stats` endpoint and SSE streaming variant (`/search/stats/stream`) show EEG days, sessions, recording time, label counts, screenshot counts, and embedding model info. Stats appear in the search UI for all modes. +- **Daemon test mode**: `POST /v1/test/begin` pauses background work (screenshots, OCR, re-embed) for stable E2E testing. `POST /v1/test/end` resumes. Debug builds only (`#[cfg(debug_assertions)]`). +- **Daemon readiness**: `/readyz` now returns `{ok, ready, test_mode}` — `ready` is set after the TCP listener binds. `SKILL_DAEMON_TOKEN` env var allows injecting auth tokens for isolated test daemons. +- **Interactive test picker**: `npm test` shows a TUI picker (Node.js, cross-platform) with 17 suites. Arrow keys + space to toggle, enter to run. +- **Rust version guard**: `npm run tauri dev` and `npm run test:clippy` check Rust >= 1.95 and prompt `rustup update stable` if outdated. + +### Bugfixes + +- Fix Chat shortcut not being loaded from saved settings on app startup. + +- **Reembed silent failures**: Added diagnostic logging on first extract failure and first encode failure per batch. Previously all epoch failures were completely silent, making it impossible to debug why 27K+ epochs would fail with zero output. +- **CSV channel length mismatch**: The CSV parser now skips entire rows when any channel value fails to parse, preventing channels from accumulating different sample counts. Previously, individual parse failures were silently skipped per-channel, creating mismatched-length arrays that the ZUNA encoder rejected. +- **Mixed-device reembedding**: CSVs with fewer columns than the metadata expects (e.g. different device in the same day directory) now use available columns instead of skipping all rows. The column count is detected per-file from the CSV header. +- **Reconnect respects test mode**: The BLE reconnect loop now pauses when `test_mode` is active, preventing background connection attempts from interfering with E2E tests. +- **`cpu-only` feature build**: Fixed compilation errors in `skill-daemon` when built with `--no-default-features --features cpu-only`. The `#[cfg(not(feature = "llm"))]` fallback paths referenced `AppState` fields that don't exist without the `llm` feature. Replaced with static stubs. This fixes the CI coverage job. +- **Clippy 1.95**: Fixed `sort_by` → `sort_by_key(Reverse)` in skill-history, `collapsible_match` in connect_wired, `checked_div` in iroh_remote. +- **Prebuilt llama naming**: Updated download URL from `q1-metal`/`q1-vulkan` to `metal-static`/`vulkan-static` to match current release asset names. + +- **Fix macOS DMG creation stream error**: Added retry logic (up to 3 attempts) to the `appdmg` invocation in `scripts/create-macos-dmg.sh`. The `ERR_STREAM_WRITE_AFTER_END` race condition in appdmg's internal file-copy stream is now caught and retried automatically, with partial DMG cleanup between attempts. + +- **Search hang fixed**: Removed expensive `collect_search_meta` (session JSON parsing) from the search hot path. Corpus stats are now fetched separately via a dedicated endpoint. +- **Label stale count**: `count_needing_embed()` uses `SELECT COUNT(*)` instead of loading all stale rows into memory. +- **Heredoc terminators**: Fixed indented heredoc terminators (`DPLIST`, `PYEOF`) in release-mac, release-linux, and pr-build workflows that caused `syntax error: unexpected end of file`. +- **Flaky env var test**: Added `Mutex` guard to `enforce_path_integrity` tests in skill-tools to prevent parallel env var races. +- **A11y errors**: Added `aria-label` to 3 unlabeled inputs in `EegModelTab.svelte`. +- **Smoke test auth**: Added Bearer token to all HTTP requests in `test.ts` (was sending unauthenticated). Updated 20+ REST paths from old Tauri-era shortcuts to `/v1/` daemon endpoints. Score: 83/275 → 341/0. +- **E2E test isolation**: Daemon token and data E2E tests now spawn isolated daemons on random ports with temp skill dirs. Can run in parallel. +- **Pre-checkout CI step**: `free-disk-space` step inlined for workflows that run it before `actions/checkout`. +- **ESM import fix**: Removed Node 22-only `glob` import and `require()` calls from `ci.mjs` (broke on Node 20 CI runners). + +### Refactor + +- **Dead scripts removed**: Deleted `prepare-daemon-sidecar.sh` (superseded by `.js`), `adopt_anyhow.py` (unused), `check_windows_manifest.py` (ported to Node). +- **Screenshot store reuse**: Interactive search opens the screenshot store once and reuses it for both proximity and OCR searches. +- **E2E helpers**: New `src/tests/e2e-helpers.ts` with `spawnDaemon()`, `testBegin()`/`testEnd()`, `isDaemonReady()`, `freePort()` shared across all E2E test files. + +### Build + +- **CI scripts consolidated**: Replaced ~960 lines of duplicated inline YAML with a single `scripts/ci.mjs` (Node.js, 12 subcommands). Zero Python dependency for builds — all scripts are Node.js + bash. +- **Release workflows refactored**: All 3 release workflows (macOS, Linux, Windows) support `workflow_dispatch` for on-demand builds. On-demand runs upload artifacts (14-day retention) without touching GitHub Releases. +- **macOS .app bundling**: Extracted 120-line inline YAML into `scripts/assemble-macos-app.sh`. Fixed heredoc terminator bug that caused CI failures. +- **Pre-push hook**: Runs `ci.mjs self-test` when CI scripts change, and `daemon-client.test.ts` when daemon proxy files change. +- **CI dry-run**: `npm run ci:dry-run` runs the full release pipeline locally without signing/pushing. `--skip-compile` for fast iteration. +- **Clippy 1.95 fixes**: `sort_by` → `sort_by_key(Reverse)`, collapsible match arms, `checked_div`. + +### Server + +- Add `POST /v1/search/commands` endpoint for semantic command search. Accepts a query and candidate list, embeds both with nomic-embed-text-v1.5 via fastembed, and returns the top 5 results ranked by cosine similarity. + +- `GET /v1/models/estimate-reembed` response now includes `embedded`, `missing`, `coverage_pct`, `avg_embed_ms`, `eta_secs`, `per_day` (per-day breakdown array), and `idle_reembed` (background reembed status with active/idle_secs/done/total/current_day). +- `GET /v1/search/stats/stream` slow-tier stats now include `eeg_total_epochs`, `eeg_embedded_epochs`, and `eeg_missing_epochs`. +- New `IdleReembedStatus` struct in daemon state tracks background reembed progress and idle countdown, updated in real-time by the idle reembed loop. + +## [0.0.121] — 2026-04-15 + +### Features + +- update failing apple notary tool + +## [0.0.120] — 2026-04-15 + +### Features + +- updated API docs + +## [0.0.119] — 2026-04-14 + +### Features + +- added openblas to ci +- fixed tests limits +- missing keys + +## [0.0.118] — 2026-04-14 + +### Features + +- updated neuroloop +- neuroloop 0.1.1 +- added new logo +- updated neuroloop and neuroskill +- added korean and a11y + fixed i18n +- merged PRs +- deps(cargo): bump the cargo-all group across 1 directory with 8 updates +- deps(npm): bump the npm-all group with 11 updates +- ndarray optimization +- updated embeddings and hnsw +- zuna-rs 0.0.4 — zero-cost model init (no random waste) +- fixed labels + +### Compare Window Performance + +- **SQLite timestamp index** — Added `CREATE INDEX idx_embeddings_timestamp ON embeddings(timestamp)` to all day databases. Range queries on the `embeddings` table went from full table scan to indexed lookup (40ms → 5ms for COUNT on 146K rows). +- **Directory date filtering** (`dirs_for_range`) — Compare endpoints now only open SQLite files for days that overlap the requested time range, skipping irrelevant directories (e.g., 363 of 365 day dirs skipped for a 2-day comparison). +- **Single JSON parse per row** — Replaced 49-50 `json_extract()` calls per SQLite row with `SELECT metrics_json` + one serde deserialize in Rust. For `get_session_metrics` on 60K rows: 1.03s → 82ms (12x faster). +- **SQL-level timeseries downsampling** — `get_session_timeseries` now uses `ROW_NUMBER() OVER ... WHERE rn % step = 1` to return ~800 evenly-spaced rows instead of all 145K. Transfer + serde reduced from ~3s to ~40ms. +- **Read-only SQLite connections** — Analysis endpoints now use `SQLITE_OPEN_READ_ONLY` to eliminate write-lock contention when 6+ parallel queries run during compare. +- **Two-phase progressive loading** — Compare UI shows metrics first (fast SQL query), then loads sleep and timeseries in the background. Results appear in <100ms instead of waiting 3+ seconds. +- **CSS `.spin` animation** — Added missing `@keyframes spin` and `.spin` class to `app.css`. Loading spinners across the app were using the class but it was never defined. + +### UMAP / Brain Nebula Fixes + +- **Poll handler: "running" status** — Fixed `pollUmapJob` to treat `status: "running"` same as `"pending"` (was silently calling `finishUmap()` and aborting the poll loop). +- **HTTP timeout for UMAP** — UMAP computation request now uses 5-minute timeout instead of the default 10 seconds (which killed the request mid-computation). +- **Empty embedding detection** — `umap_compute_inner` now filters out 0-length embedding BLOBs, reports `total_a`/`total_b` (total epochs found) vs `n_a`/`n_b` (epochs with actual embeddings), and returns a `reason` field explaining why UMAP can't compute. +- **User-facing error UI** — When UMAP returns empty (no embeddings), shows a warning panel with explanation ("Brain Nebula requires EEG embedding vectors…") and a Retry button. +- **UMAP progress bar** — Added estimated progress bar during UMAP computation based on elapsed/estimated time, with countdown. Shows epoch-level progress when available from the daemon. + +### Embedding Pipeline & Batch Reembed + +- **Encoder failure logging** — Upgraded encoder load failure from `warn!` to `error!` with actionable message. Added per-epoch `metrics_only_streak` counter that logs first failure, every 100th consecutive failure, and recovery. +- **Slow encode warning** — Logs when a single epoch encode exceeds 2 seconds. +- **Batch reembed implementation** — `POST /v1/models/trigger-reembed` was a stub returning "queued". Now fully implemented: loads encoder, scans all day directories, reads raw EEG CSVs, encodes epochs, writes embeddings back to SQLite. +- **`estimate_reembed` fixed** — Now correctly counts epochs with empty `eeg_embedding` BLOBs (was always returning 0). +- **Relative CSV timestamp fix** — Batch reembed now handles both absolute Unix timestamps and relative device-internal timestamps in CSV files by using the session start time from the filename. +- **Transaction batching** — SQLite writes during reembed use batched transactions (50 epochs per COMMIT) for write performance. +- **Progress events** — Emits `reembed-progress` WebSocket events every batch with status, total, done, failed, and current day. + +### GPU-Accelerated Encoding + +- **zuna-rs 0.1.0** — Upgraded from 0.0.4. Brings parallel processing (rayon), CPU preprocessing pipeline, and `wgpu-f16` feature for half-precision GPU inference. +- **GPU encoder (Metal/Vulkan)** — New `embed-zuna-gpu` and `embed-zuna-gpu-f16` features. `ZunaGpuState` loads ZUNA on wgpu (Metal on macOS). Encoding: ~120ms/epoch on GPU vs 5,600ms on CPU (47x speedup). +- **f16 half-precision** — Default GPU precision is f16 (`embed-zuna-gpu-f16`), faster than f32 on Apple Silicon. Configurable via `gpu_precision` setting. +- **Encoder fallback chain** — `load_encoder_public` tries GPU f16 → GPU f32 → CPU (NdArray + Accelerate BLAS). + +### Idle Background Reembed + +- **`idle_reembed` background loop** — New `idle_reembed.rs` module spawned at daemon startup. Monitors device connection state every 10 seconds. When device is disconnected for `idle_reembed_delay_secs` (default 30 minutes), starts background embedding of unprocessed epochs. +- **Backpressure on device connect** — `idle_reembed_cancel` AtomicBool flag set to `true` in `DeviceEvent::Connected` handler. Batch reembed checks cancel flag between every batch and pauses immediately, emitting a "paused" status event. +- **Configurable settings** — `ReembedConfig` extended with `idle_reembed_enabled`, `idle_reembed_delay_secs`, `idle_reembed_gpu`, `gpu_precision`, `idle_reembed_throttle_ms`. All exposed via `GET/POST /v1/settings/reembed-config`. + +### Embedding Status UI + +- **Timeline picker indicator** — Small colored dot next to session status: green (90%+ embedded), amber + percentage (partial), red (0% embedded). +- **UMAP section indicator** — "Calculate UMAP" button disabled when <5 embeddings available, with explanation text. Shows reembed progress bar when background embedding is running. +- **Reembed progress in compare** — Live progress bar with rate estimate (e.g., "6.3 embeddings/sec") and ETA (e.g., "~11h 32m remaining") from WebSocket `reembed-progress` events. +- **EEG Model settings** — Added GPU Precision dropdown (f16/f32), Background Embedding toggle, and Idle Delay selector (5min/15min/30min/1hr) to the EEG Model tab. + +### Search Improvements + +- **Interactive search implemented** — `POST /v1/search/eeg` now handles `interactive_search` requests: embeds query text → searches label HNSW → finds nearby EEG epochs → discovers temporal neighbor labels. Returns full graph with nodes and edges. +- **Text search routing fixed** — `search_labels_by_text` was routed to the wrong endpoint (`/v1/search/eeg` which returned `{ results: [] }`). Now correctly routes to `/v1/labels/search` with response unwrapping. +- **EEG search: empty embedding filter** — `read_embeddings_in_range` now filters `WHERE length(eeg_embedding) >= 4` at the SQL level, avoiding loading and searching 65K+ empty BLOBs. Search time for 66K epoch range: >60s → 3.6s. +- **SSE streaming search** — New `POST /v1/search/eeg/stream` endpoint sends results as Server-Sent Events as they're found. Frontend reads via `ReadableStream` with fallback to full-body read. Results appear progressively in the UI. +- **Cancel search** — `cancelSearch()` exported from invoke-proxy aborts the SSE fetch. Cancel button in search UI now actually stops the streaming request (not just sets a flag). +- **Channel command timeouts** — Search uses 2-minute timeout, chat uses 5-minute timeout (was 10 seconds for both). +- **Null-safety** — Added comprehensive null guards for `nb.labels`, `nb.distance`, `nb.neighbors`, `q.neighbors`, `x.text`, `x.context` across search page and search-types. Prevents crashes when daemon returns entries with null/undefined fields. +- **Streaming performance** — `$derived` computations (`searchAnalysis`, `temporalHeatmap`, `filtered`) skip during active streaming to avoid O(n^2) recomputation on every incoming result. + +### Screenshot Image Serving + +- **Static file serving** — Daemon now serves screenshot images at `/screenshots/{filename}` and `/screenshots/{date}/{filename}`. Infers the `YYYYMMDD` subdirectory from the first 8 characters of the filename. +- **Auth required** — Screenshot routes go through the auth middleware. `` tags use `?token=` query parameter for authentication. Token loaded from daemon bootstrap at page mount. + +### Search Tab UX + +- **Active tab styling** — Search mode tabs now have clear visual distinction: inactive tabs are dimmed (45% opacity), active tab has bold text, accent-colored background tint, and a solid 2px bottom border. + +### Crash Fix + +- **muda ZeroWidth panic** — Patched `muda 0.17.2` locally to replace `.unwrap()` with safe fallbacks in `to_png()` (macOS icon.rs). Prevents crash when macOS event loop processes a zero-width icon image during window creation. + +### i18n + +- Added embedding status translations (`compare.embeddingsReady`, `compare.embeddingsPartial`, `compare.embeddingsNone`, `compare.embeddingsProcessing`) to all 9 locales (en, de, es, fr, he, ja, ko, uk, zh). +- Added GPU/idle reembed translations (`model.gpuPrecision`, `model.gpuPrecisionDesc`, `model.gpuPrecisionF16`, `model.gpuPrecisionF32`, `model.idleReembed`, `model.idleReembedDesc`, `model.idleDelay`) to all 9 locales. + +### Tests + +- **compare-analysis-e2e.test.ts** — 18 end-to-end tests against live daemon covering metrics, timeseries, sleep, UMAP, parallel requests, and invoke-proxy routing. +- **search-null-safety.spec.ts** — Playwright test for EEG/text/interactive search with null/undefined fields in results. + +## [0.0.117] — 2026-04-13 + +### Features + +- neuroskill +- fixed cargo clippy +- fixed svelte errors +- updated main app for neuroskill and neuroloop +- Neuroloop TUI: +- Daemon: +- Neuroloop provider registration: +- No Tauri changes needed — the desktop UI already handles tags natively with collapsible thinking blocks. +- fixed neuroskill + added japanese +- fixed neuroskill +- updated neuroloop +- updated iroh +- updated neuroskill +- tests +- updated cli +- updated faq +- refactored faq and help +- updated disconnect +- added submodules +- fixed tests +- biome fixes + +## [0.0.116] — 2026-04-11 + +### Features + +- Minor updates and improvements + +## [0.0.115] — 2026-04-11 + +### Features + +- fixed a bug with the labels, libomp, cpu/gpu backend choice for llm + +## [0.0.114] — 2026-04-11 + +### Features + +- migrated to nomic-ai/nomic-embed-text-v1.5 model for text and image embeddings +- cleanup +- Task 8 — Removed mirror state (~150 lines) +- Task 9 — Removed device upsert helpers (~90 lines) +- Task 10 — Removed Cortex WS state (~55 lines) +- Task 11 — Refactored settings persistence +- Task 12 — Removed calibration profile local cache + +## [0.0.113] — 2026-04-11 + +### Features + +- updated CI +- Daemon side (business logic added) +- crates/skill-daemon/src/background.rs: +- crates/skill-daemon/src/handlers.rs: +- crates/skill-daemon/src/routes/settings_calibration.rs: +- Tauri side (logic removed) +- src-tauri/src/background.rs: +- src-tauri/src/lifecycle.rs: +- Moved to daemon / removed from Tauri +- 1. detect_device_kind() — Added DeviceKind::from_id_and_name() to skill-data/src/device.rs with the ID-prefix logic (cortex:, usb:, cgx:, etc.) that was missing from the existing from_name(). Removed the dead function + 250 + +## [0.0.112] — 2026-04-11 + +### Features + +- New daemon files created: +- - crates/skill-daemon/src/reconnect.rs — Reconnect state machine (backoff, retry countdown, MAX_RETRY_ATTEMPTS) with background loop and REST endpoints +- Daemon state.rs changes: +- - Added broadcast() convenience method on AppState +- Daemon main.rs changes: +- - Registered new modules (reconnect, monitor, background) +- Daemon session metadata enriched: +- - write_session_meta_full() now accepts optional StatusResponse for richer metadata (battery, signal quality, device identity, phone info) +- Tauri files simplified: +- - lifecycle.rs — Removed reconnect state machine (130+ lines). go_disconnected now just cleans local state and delegates to daemon +- updated coverage.ci +- Removed direct deps (37 total): +- lean tauri +- decouple tauri from wgpu +- chinese +- exclude skill from coverage ci +- Both GPU and CPU modes compile. Here's a summary of all changes made: +- 1. scripts/create-macos-dmg.sh — Fixed unbound CLEANUP_DIRS[@] under set -u +- fixed ci +- updated CI + +## [0.0.111] — 2026-04-11 + +### Features + +- updated DMG version +- fixed the build +- llama linking fixed + +## [0.0.110] — 2026-04-11 + +### Features + +- feat: add CPU backend support to skill-router for CI coverage +- Add a new feature to skill-router that uses CPU-based UMAP +- Changes: +- The GPU backend remains the default for normal builds, while the +- fix: disable all GPU-dependent features in coverage workflow +- The coverage CI was still failing because the embedding features +- Solution: Disable all embedding features in the coverage workflow +- The embedding functionality will still be tested in local +- fix: add llm-native feature and use it in coverage workflow +- The coverage CI was failing because is not a valid +- Solution: +- This ensures that llama-cpp-4 is statically linked in the coverage +- fix: add native feature to llm in coverage workflow +- The coverage CI was failing because it couldn't find libllama.so.0. +- Solution: Change to in the coverage workflow's +- This matches the Cargo.toml configuration where we added the native +- fix: remove aggressive -static flag from macOS config +- The -static flag in the macOS cargo config was causing issues and +- Instead, we rely on: +- This change ensures that: +- Verified that skill-daemon runs without libllama.0.dylib present. +- chore: enable static linking for llama-cpp-4 +- Add the feature to llama-cpp-4 dependencies to enable static +- This change: +- Static linking improves: +- The binary size will increase, but this is acceptable for a desktop +- chore: update llama-cpp-4 to 0.2.43 +- Update llama-cpp-4 dependency from version 0.2.42 to 0.2.43. +- This brings in the latest improvements and bug fixes from the +- The update is applied to all feature variants (ggml, metal, vulkan). +- fix: disable GPU features in coverage CI +- The coverage CI was failing because tests that use GPU acceleration +- Solution: Run coverage tests with CPU-only features by: +- This allows the coverage workflow to complete successfully while still +- revert: remove CI checks from tests +- The previous approach of checking CI environment inside tests didn't work +- This commit reverts the CI checks and we'll try a different approach +- fix: use CPU-only backend in CI tests +- The coverage CI was still failing because even though we added CI checks, +- Solution: Modify test_state() to use the LUNA CPU-only backend instead of +- LUNA is a topology-agnostic encoder that runs entirely on CPU, making it +- fix: skip GPU tests in CI environment +- Solution: Add a check for the CI environment variable at the beginning +- Tests affected: + +## [0.0.109] — 2026-04-10 + +### Features + +- fix: disable dynamic linking for llama-cpp-4 +- The llama-cpp-4 crate doesn't have a 'static' feature. Instead, it has +- To get static linking, we need to: +- This will embed the llama.cpp libraries directly into the skill-daemon +- Note: We keep the 'ggml' feature explicitly enabled since it's not in +- fix: force static linking for llama-cpp-4 in skill-daemon +- The skill-daemon binary was failing to launch on macOS with: +- This occurred because llama-cpp-4 was dynamically linking to its +- Solution: Configure llama-cpp-4 to use static linking by adding +- Changes: +- This makes the skill-daemon binary larger but more portable and + +## [0.0.106] — 2026-04-10 + +### Features + +- fix: add APPLE_SIGNING_IDENTITY to DMG creation step +- The DMG creation step was missing the APPLE_SIGNING_IDENTITY environment +- The script already supports using APPLE_SIGNING_IDENTITY via the SIGN_ID + +## [0.0.104] — 2026-04-10 + +### Features + +- feat: implement cross-platform daemon update hooks +- - Add pre-update and post-update hooks for Tauri updater +- The daemon is now properly stopped before updates and restarted afterward, +- Fix Windows release: check both target paths for skill.exe +- The release workflow was failing because the 'Log binary dependencies' step +- This change makes the step check both locations (target-specific first, then + +## [0.0.103] — 2026-04-10 + +### Features + +- chore(bump): improve release notes generation from git history +- Updates the bump script to generate better release notes from commit history: +- The script now always generates release notes based on actual commit history +- feat(llm): add Nemotron-3-Nano-4B model to catalog +- Adds NVIDIA's Nemotron-3-Nano-4B model with the available Q4_K_M quant. +- Also simplifies pre-commit hook to run only basic validation, +- chore: update llama-cpp-4 from 0.2.36 to 0.2.38 +- Updates llama-cpp-4 and llama-cpp-sys-4 to latest versions. +- bump +- fix(calibration): improve daemon integration and error handling +- - Fix EEG window submission to daemon with proper eeg_start/eeg_end timestamps +- This ensures calibration labels are properly recorded with correct EEG windows + +## [0.0.102] — 2026-04-10 + +### Features + +- mtmd fix +- improved windows TUI +- added postinstall +- minor update +- win + linux +- mac +- better coverage +- improved coverage +- try_send instead of blocking_send +- fix: spawn llm_chat streaming as tokio task to prevent deadlock +- feat: draft prefill, LLM start UX fixes, chat input multiline alignment +- fix: align dev TUI header art +- fix: stabilize iroh remote sessions and dashboard streaming status +- chore: silence non-actionable cargo-deny license metadata warnings +- chore: commit all pending changes +- ci: run coverage without all-features on non-CUDA runners +- chore: update tests, config, and clients tab +- Fix iroh session stability, revoke/scope URLs, and metrics continuity +- Add start_session / cancel_session WS commands; exempt peer: from pairing check +- Wire IrohRemoteAdapter into daemon session runner +- Authenticate iroh peers via tunnel identity instead of bearer token + +## [0.0.101] — 2026-04-10 + +### Features + +- mtmd fix +- improved windows TUI +- added postinstall +- minor update +- win + linux +- mac +- better coverage +- improved coverage +- try_send instead of blocking_send +- fix: spawn llm_chat streaming as tokio task to prevent deadlock +- feat: draft prefill, LLM start UX fixes, chat input multiline alignment +- fix: align dev TUI header art +- fix: stabilize iroh remote sessions and dashboard streaming status +- chore: silence non-actionable cargo-deny license metadata warnings +- chore: commit all pending changes +- ci: run coverage without all-features on non-CUDA runners +- chore: update tests, config, and clients tab +- Fix iroh session stability, revoke/scope URLs, and metrics continuity +- Add start_session / cancel_session WS commands; exempt peer: from pairing check +- Wire IrohRemoteAdapter into daemon session runner +- Authenticate iroh peers via tunnel identity instead of bearer token + +## [0.0.100] — 2026-04-10 + +### Features + +- mtmd fix +- improved windows TUI + +## [0.0.99] — 2026-04-10 + +### Features + +- added postinstall +- minor update +- win + linux +- mac +- better coverage +- improved coverage +- try_send instead of blocking_send +- fix: spawn llm_chat streaming as tokio task to prevent deadlock +- feat: draft prefill, LLM start UX fixes, chat input multiline alignment +- fix: align dev TUI header art +- fix: stabilize iroh remote sessions and dashboard streaming status +- chore: silence non-actionable cargo-deny license metadata warnings +- chore: commit all pending changes +- ci: run coverage without all-features on non-CUDA runners +- chore: update tests, config, and clients tab +- Fix iroh session stability, revoke/scope URLs, and metrics continuity +- Add start_session / cancel_session WS commands; exempt peer: from pairing check +- Wire IrohRemoteAdapter into daemon session runner +- Authenticate iroh peers via tunnel identity instead of bearer token +- Fix TUI line truncation caused by unstripped OSC hyperlink sequences +- Fix axum 0.7 path param syntax in iroh routes (:{id} → {id}) +- Use dedicated port 18445 for dev daemon to avoid system service conflict +- Kill stale daemon on dev startup and pin SKILL_DAEMON_BIN to local build + +## [0.0.98] — 2026-04-10 + +### Features + +- added postinstall +- minor update +- win + linux +- mac +- better coverage +- improved coverage +- try_send instead of blocking_send +- fix: spawn llm_chat streaming as tokio task to prevent deadlock +- feat: draft prefill, LLM start UX fixes, chat input multiline alignment +- fix: align dev TUI header art +- fix: stabilize iroh remote sessions and dashboard streaming status +- chore: silence non-actionable cargo-deny license metadata warnings +- chore: commit all pending changes +- ci: run coverage without all-features on non-CUDA runners +- chore: update tests, config, and clients tab +- Fix iroh session stability, revoke/scope URLs, and metrics continuity +- Add start_session / cancel_session WS commands; exempt peer: from pairing check +- Wire IrohRemoteAdapter into daemon session runner +- Authenticate iroh peers via tunnel identity instead of bearer token +- Fix TUI line truncation caused by unstripped OSC hyperlink sequences +- Fix axum 0.7 path param syntax in iroh routes (:{id} → {id}) +- Use dedicated port 18445 for dev daemon to avoid system service conflict +- Kill stale daemon on dev startup and pin SKILL_DAEMON_BIN to local build + +## [0.0.97] — 2026-04-10 + +### Bugfixes + +- **Fix `spawnSync npm.cmd EINVAL` on Windows**. Added `shell: true` to `execFileSync` calls when the resolved Tauri CLI command is a `.cmd` shim. Windows `.cmd` files are batch scripts that require shell execution. + +- **Fix coverage CI: skip EXG embedding worker in tests**. Session runner tests panicked on headless CI (no GPU) because `cubecl-wgpu` tried to initialize a Vulkan adapter. Added `cfg!(test)` check to skip the embedding worker during test builds. Also supports `SKILL_SKIP_EMBED=1` env var for headless production environments. + +### Build + +- **Fix release CI: build daemon before Tauri app**. Reversed the build order so `skill-daemon` compiles first using its default features (which resolve `llama-cpp-sys-4` with `mtmd` + Metal/Vulkan + `q1` via `skill-llm`'s target-specific deps). The Tauri app build runs second and reuses cached native libs. Removed explicit `--features llm-metal`/`llm-vulkan` flags — daemon defaults handle GPU backend selection per platform automatically. + +## [0.0.96] — 2026-04-10 + +### Bugfixes + +- **LLM server enabled state now persists across daemon restarts**. When the user starts the LLM server, `config.enabled = true` is saved to disk. When stopped, `config.enabled = false` is saved. On daemon startup, if `enabled` is `true`, the LLM server auto-starts. Previously, stopping the server did not persist the disabled state, and the daemon never auto-started on boot. + +- **Fix TUI dev mode on Windows**. Enabled TUI by default on all platforms. Fixed `killChildTree` to use `taskkill /T /F /PID` on Windows instead of unsupported negative-PID process group kill. Set `detached: false` on Windows to prevent new console windows. Added robust try/catch around terminal escape sequences and raw mode setup. Graceful fallback to standard dev mode on any platform if TUI fails. + +- **Fix `npm run tauri dev` ENOENT on Windows**. The Tauri CLI resolver used bare `npm` which fails with `spawnSync npm ENOENT` because Windows requires `npm.cmd` for `execFileSync`. Now uses `npm.cmd`/`npx.cmd` on Windows. + +### Refactor + +- **Thin Tauri app: LLM engine, NeuTTS, and EXG embedding removed from the UI binary**. The Tauri app is now a thin UI shell that communicates with `skill-daemon` over HTTP/WebSocket. `llama-cpp-4`, `neutts`, and all EXG encoder crates (`zuna-rs`, `luna-rs`, `tribev2`, `burn`, `fastembed`, `ort`, etc.) are no longer compiled into the Tauri binary — they live exclusively in the daemon. This dramatically reduces Tauri compile time and binary size. All LLM Tauri commands (catalog, downloads, server lifecycle, chat completions, streaming) continue to work unchanged as daemon HTTP proxies. The `llm` module is always compiled but engine-specific code (`init`, `shutdown`, direct inference) is gated behind `#[cfg(feature = "llm")]` with lightweight stub types (`LlmStatus`, `LlmLogBuffer`) provided when the feature is off. + +### Build + +- **Fix release CI: daemon and Tauri app built and bundled together across all OS**. The daemon sidecar is now built alongside the Tauri app in release workflows on all three platforms. macOS uses `--features llm-metal`, Windows and Linux use `--features llm-vulkan`. The Tauri app itself only needs `--features custom-protocol` (no LLM features). Fixed macOS PKG step ordering (`.app` bundle assembly now runs before PKG staging). Removed conflicting system-level service installations — the daemon self-registers as a user LaunchAgent/systemd user unit/Windows service at runtime. Added `scripts/pkg-scripts/postinstall` for macOS PKG. Windows NSIS now includes daemon process/service cleanup and firewall rules on install/uninstall. Linux `package-linux-system-bundles.sh` and `package-linux-dist.sh` updated to find and bundle the daemon binary. + +- **Fix `npm run bump` changelog compilation**. Uncommented the `compileChangelog` import and fragment validation that were disabled. Bump now compiles `changes/unreleased/*.md` fragments into `changes/releases/.md` and rebuilds `CHANGELOG.md`. When no fragments exist, auto-generates one from git commit history since the last version. All file mutations are wrapped in try/catch with automatic `git checkout` rollback on failure. Commit uses `git add -A` and runs pre-commit hooks (no `--no-verify`). + +## [0.0.95] — 2026-04-10 + +### Features + +- added postinstall +- minor update +- win + linux +- mac +- better coverage +- improved coverage +- try_send instead of blocking_send +- fix: spawn llm_chat streaming as tokio task to prevent deadlock +- feat: draft prefill, LLM start UX fixes, chat input multiline alignment +- fix: align dev TUI header art +- fix: stabilize iroh remote sessions and dashboard streaming status +- chore: silence non-actionable cargo-deny license metadata warnings +- chore: commit all pending changes +- ci: run coverage without all-features on non-CUDA runners +- chore: update tests, config, and clients tab +- Fix iroh session stability, revoke/scope URLs, and metrics continuity +- Add start_session / cancel_session WS commands; exempt peer: from pairing check +- Wire IrohRemoteAdapter into daemon session runner +- Authenticate iroh peers via tunnel identity instead of bearer token +- Fix TUI line truncation caused by unstripped OSC hyperlink sequences +- Fix axum 0.7 path param syntax in iroh routes (:{id} → {id}) +- Use dedicated port 18445 for dev daemon to avoid system service conflict +- Kill stale daemon on dev startup and pin SKILL_DAEMON_BIN to local build +- Move iroh tunnel to daemon and add /v1/iroh/* HTTP routes +- Handle Windows STATUS_CONTROL_C_EXIT as graceful dev shutdown + +## [0.0.94] — 2026-04-10 + +### Features + +- added postinstall +- minor update +- win + linux +- mac +- better coverage +- improved coverage +- try_send instead of blocking_send +- fix: spawn llm_chat streaming as tokio task to prevent deadlock +- feat: draft prefill, LLM start UX fixes, chat input multiline alignment +- fix: align dev TUI header art +- fix: stabilize iroh remote sessions and dashboard streaming status +- chore: silence non-actionable cargo-deny license metadata warnings +- chore: commit all pending changes +- ci: run coverage without all-features on non-CUDA runners +- chore: update tests, config, and clients tab +- Fix iroh session stability, revoke/scope URLs, and metrics continuity +- Add start_session / cancel_session WS commands; exempt peer: from pairing check +- Wire IrohRemoteAdapter into daemon session runner +- Authenticate iroh peers via tunnel identity instead of bearer token +- Fix TUI line truncation caused by unstripped OSC hyperlink sequences +- Fix axum 0.7 path param syntax in iroh routes (:{id} → {id}) +- Use dedicated port 18445 for dev daemon to avoid system service conflict +- Kill stale daemon on dev startup and pin SKILL_DAEMON_BIN to local build +- Move iroh tunnel to daemon and add /v1/iroh/* HTTP routes +- Handle Windows STATUS_CONTROL_C_EXIT as graceful dev shutdown + +## [0.0.93] — 2026-04-10 + +### Bugfixes + +- **Fix TUI dev mode on Windows**. Enabled TUI by default on all platforms (was disabled on Windows). Fixed `killChildTree` to use `taskkill /T /F /PID` on Windows instead of unsupported negative-PID process group kill. Set `detached: false` on Windows to prevent new console windows. Added robust try/catch around terminal escape sequences and raw mode setup. Graceful fallback to standard dev mode on any platform if TUI fails. + +- **Fix `npm run tauri dev` ENOENT on Windows**. The Tauri CLI resolver fell through to bare `npm` which fails with `spawnSync npm ENOENT` because Windows requires `npm.cmd` for `execFileSync`. Now uses `npm.cmd`/`npx.cmd` on Windows. + +### Build + +- **Fix release CI: daemon + Tauri app bundled together across all OS**. The `skill-daemon` sidecar is now built alongside the Tauri app in a single `cargo build -p skill -p skill-daemon` invocation on all three platforms. This ensures cargo's feature unification produces one consistent `llama-cpp-sys-4` build with `mtmd` + GPU backend + `q1` features, fixing undefined `_mtmd_*` linker errors that occurred when building the daemon separately. macOS uses `--features llm-metal`, Windows and Linux use `--features llm-vulkan,screenshots`. + +- **Fix macOS PKG step ordering**: moved `.app` bundle assembly before PKG staging so the `.app` exists when the installer copies it. Removed conflicting system-level `/Library/LaunchDaemons` installation — the daemon self-registers as a user LaunchAgent at runtime via its `/service/install` HTTP endpoint. + +- **Fix Windows NSIS: premature compile step removed**. A "Compile (frontend + Rust + daemon)" step ran before LLVM 19 was installed, causing `__builtin_ia32_*` bindgen errors from VS2022's clang-cl headers. Daemon build now runs in the existing Compile step after LLVM 19 is available. Added daemon process/service cleanup and firewall rules to NSIS install/uninstall. + +- **Fix Linux packaging: daemon bundled in deb/rpm/portable tarball**. The staging step no longer references a nonexistent AppImage. Removed conflicting `/etc/systemd/system` service file — the daemon self-registers via `systemctl --user` at runtime. Updated `package-linux-system-bundles.sh` and `package-linux-dist.sh` to find and bundle the daemon binary from the release target directory. + +- **Add `scripts/pkg-scripts/postinstall`** for macOS PKG installer (required by `pkgbuild --scripts`). + +## [0.0.88] — 2026-04-09 + +### Bugfixes + +- **Fix daemon sidecar copy warning during build prep**: updated `scripts/prepare-daemon-sidecar.sh` to skip self-copy when source and destination are identical, removing false warning noise during release builds. +- **Daemon startup upgrade resilience**: added runtime readiness flow that performs protocol compatibility checks, recovery restart attempts, rollback snapshot fallback, and background-service repair. + +### Build + +- **Bundle daemon sidecar in packaging workflows**: ensured packaging scripts include `skill-daemon`/`skill-daemon.exe` alongside the app binary across manual bundle flows (macOS app/DMG assembly and Windows NSIS staging/install/uninstall paths), and added cross-platform packaging validation scripts (`scripts/test-daemon-packaging.sh` and `scripts/test-daemon-packaging.ps1`). + +### Server + +- **Daemon runtime service hardening**: app startup now ensures daemon runtime readiness (run → protocol gate → restart/rollback recovery) and auto-heals background service registration/status via daemon service endpoints. + +### Docs + +- **Document daemon packaging + service lifecycle checks**: expanded `docs/DEVELOPMENT.md`, `docs/WINDOWS.md`, `docs/LINUX.md`, and `docs/architecture.md` with daemon bundling validation commands, service behavior, and rollback/runtime-readiness architecture notes. + +## [0.0.87] — 2026-04-08 + +### Features + +- **Daemon logs in dev terminal**: `npm run tauri dev` now streams daemon + `tracing` output into the same terminal as Tauri logs. A custom + `TeeWriter` tees every log event to both stderr and a 512-line ring buffer + in `AppState`; `GET /v1/log/recent?since=` exposes the buffer; the + Tauri process polls it every second and `eprintln!`s new lines, which the + existing stderr tee also saves to the session log file. + +- **Event-driven BLE device discovery** replaces the old polling approach + that created a new `BtManager` (and thus a new `CBCentralManager`) on + every 5-second scanner tick. A single `run_ble_listener_task` now holds + the adapter alive for the lifetime of the scanner, subscribes to + `CentralEvent::DeviceDiscovered` / `DeviceUpdated`, and updates a shared + `ble_device_cache` as advertisements arrive. This ensures `local_name` + is populated (CoreBluetooth fills it asynchronously; it was often `None` + in a short poll window) and means the Muse S shows up by name instead of + as an anonymous UUID. + +- **EEG-only BLE filter**: the BLE listener now only surfaces devices whose + advertising name matches a known EEG/neurofeedback pattern (`muse*`, + `ganglion*`, `mw75`, `brainbit`, `unicorn`, `hermes`, `mendi`, `idun`, + etc.). Previously every BLE peripheral in range — phones, speakers, + watches, UUIDs with no name — appeared in the Discovered Devices list. + +- **Fast BLE connect path** for all five BLE device families (Muse, MW75, + Hermes, Mendi, IDUN Guardian): replaced the fixed-sleep `scan_all()` call + (3–5 s) with `connect()`, which polls `adapter.peripherals()` every 250 ms + and exits as soon as the target device appears. For a paired device that + is already advertising this drops the scan phase from ~5 s to ~250 ms. + The paired device's name is looked up from `status.paired_devices` and + passed as `name_prefix` so `connect()` matches the exact headset rather + than the first device of that family. A slow `scan_all()` fallback is + kept for Muse only when no name is known (first-time unpaired connect). + +- **PPG data now written to session CSV/Parquet**: `DeviceEvent::Ppg` frames + are now passed to `pipe.writer.push_ppg()` in the session runner. + Previously PPG was only broadcast over WebSocket; it was never recorded. + Muse PPG (optical heart-rate channels: Ambient, Infrared, Red) now + appears in session files alongside EEG, IMU, and band metrics. PPG + heart-rate / HRV metrics (`PpgAnalyzer`) are now computed by the daemon + and written alongside raw PPG samples in each epoch. + +- **fNIRS recording pipeline**: full end-to-end fNIRS data persistence for + devices like Mendi that provide optical brain-imaging data instead of EEG. + + - `DeviceEvent::Fnirs(FnirsFrame)` — new event variant carrying raw + multi-channel photodetector ADC values + device timestamp. + - `DeviceCaps::FNIRS` flag added to the capability bitflags. + - `MendiAdapter` now emits `DeviceEvent::Fnirs` (9 channels: IR/Red/Ambient + × Left/Right/Pulse) instead of packing optical data into a raw-JSON + `DeviceEvent::Meta`. Temperature-only diagnostics remain as `Meta`. + - `SessionCsvWriter::push_fnirs()` — lazily creates `exg_*_fnirs.csv` with + `timestamp_s` + per-channel columns using the device's + `fnirs_channel_names`. Auto-flushes every 64 rows. + - `SessionParquetWriter::push_fnirs()` — lazily creates `exg_*_fnirs.parquet` + with a Snappy-compressed schema built dynamically from channel names. + Batches 256 rows before flushing. Properly integrated into `flush()` and + `close()` lifecycle. + - `SessionWriter::push_fnirs()` dispatches to CSV, Parquet, or both based on + the user's `storage_format` setting. + - Session runner: `DeviceEvent::Fnirs` is written to disk and broadcast as a + `FnirsSample` WebSocket event. + +- **Firmware version shown live on the dashboard**: the connected-device + info row (below the device name) now includes `fw ` when the + Muse sends its firmware version string via a Control JSON event, e.g. + `fw 3.4.5`. Previously the value was extracted and stored in + `state.status.firmware_version` but never surfaced in the UI. + +- **`firmware_version` and `bootloader_version` added to the TypeScript + `DeviceStatus` type**: both fields were serialised by Tauri and arrived + in every status payload but were absent from the interface declaration in + `types.ts`, so the TypeScript compiler would silently discard them. + +- **Session sidecar (`exg_*.json`) now includes `firmware_version` and + `serial_number`**: the `Pipeline` struct stores both values when + `DeviceEvent::Connected` arrives and updates `firmware_version` again + when the Muse's Control JSON event fires (which arrives a few seconds + after connect, after the initial `DeviceEvent::Connected`). + `write_session_meta()` in `shared.rs` now writes both fields. + +- **LSL dashboard integration**: The dashboard now shows connected state, + signal quality, live EEG channel values, band powers, and waveforms for + LSL streams — previously only BLE devices updated the dashboard. +- **Generic device badge**: Replaced 6 device-specific badge branches with + a single badge showing channel count, sample rate, and transport type. +- **Collapsible signal quality**: For high channel counts (>8), the quality + grid collapses behind a summary row (e.g. "32✓ 0~") to save space. +- **LSL fast resolve**: Named LSL streams now connect in ~500 ms instead of + waiting the full 5-second discovery timeout. +- **Dynamic channel support**: EEG waveforms, band power chart, and channel + grid now support any channel count (2–1024+) with a 64-channel rendering + cap for performance. +- **Disconnected view**: Added "LSL / Settings" button alongside "Scan for + Device" for quick access to LSL configuration. + +- **Paired devices persisted by the daemon** (`paired_devices.json`): + `pair_device` and `forget_device` write a dedicated + `~/.skill/paired_devices.json` on every change. The file is written + atomically (temp-file + rename) so a crash mid-write never corrupts it. + The daemon reloads it at startup to populate `status.paired_devices` + before the first scanner tick, meaning paired devices survive restarts + without Tauri needing to re-sync them. `settings.json` is also kept in + sync via a background task for backward compatibility with older builds and + the Tauri side. `skill-constants::PAIRED_DEVICES_FILE` is the shared + constant used by both daemon and Tauri. + +- **Tauri reads `paired_devices.json` directly at startup**: + `load_and_apply_settings` now prefers `paired_devices.json` + (daemon-authoritative) over `settings.json` (which may lag by the async + write). Paired devices appear correctly in the UI on the very first render + frame, before the daemon status poll completes. + +- **Muse firmware version surfaced at runtime**: the session runner now + handles `DeviceEvent::Meta` (previously silently discarded in the `_ => {}` + branch). Muse Control JSON responses contain a `"fw"` field (e.g. + `"3.4.5"`); when one arrives, `status.firmware_version` is updated and a + `StatusUpdate` WebSocket event is broadcast to connected clients. + +### Bugfixes + +- **Ctrl+C now reliably terminates both daemon and Tauri from the TUI**: switched TUI child processes to process groups and implemented graceful shutdown (`SIGTERM`) with forced fallback (`SIGKILL`) so orphaned dev processes are not left running. + +- **Daemon pane shutdown handling fixed**: replaced blocking `execSync` in TUI daemon pane mode with `spawn` + signal-aware exit handling, so termination signals propagate correctly. + +- **Muse (and all BLE devices) stuck at "Scanning for device…" forever**: + when a connection attempt launched its own `CBCentralManager` scan while + `run_ble_listener_task` was already holding a concurrent + `CBCentralManager` scan, macOS suppressed the + `centralManager(_:didConnect:)` delegate callback, causing + `peripheral.connect()` to hang regardless of the timeout. Fixed with a + `ble_scan_paused` `AtomicBool` in `AppState`: the listener task stops its + scan and parks whenever the flag is set; `connect_device()` sets the flag + before delegating to the actual connect function and clears it + unconditionally on return (success or failure). + +- **`needs_ble_pause` covers all BLE-scanning device families**: Muse, + MW75 Neuro, Hermes V1, Mendi fNIRS, IDUN Guardian (`idun`/`guardian`/ + `ige`), and OpenBCI Ganglion were all affected by the two-concurrent-scan + bug. The pause logic is now centralised in `connect_device()` rather than + duplicated in each connect function. + +- **Stale `ble_scan_paused` flag after scanner restart**: if a connection + was in progress when the scanner was stopped, the flag could be left `true` + and the freshly spawned listener task would stall indefinitely. + `control_scanner_start()` now clears the flag unconditionally. + +- **BLE discovery stops after pressing Cancel mid-connect**: + `control_cancel_retry()` cancelled the session handle but did not clear + `ble_scan_paused`. The BLE listener remained parked with its scan stopped, + so no BLE devices appeared in the Discovered list until the user either + restarted the scanner or started another connection attempt. Fixed: + `control_cancel_retry()` now clears the flag immediately. + +- **BLE event-loop timeout reduced from 2 s to 300 ms** so the + `ble_scan_paused` flag is detected quickly; the corresponding settling + pause was reduced from 600 ms to 400 ms. + +- **BLE cache TTL extended from 60 s to 120 s** to cover the worst-case + connection window (400 ms pause + 5 s scan + 10 s connect + 15 s service + discovery) without a cached device expiring mid-attempt. + +- **Daemon log `TeeWriter` never committed to ring buffer**: + `tracing-subscriber` calls `write_all()` once per event but never calls + `flush()`. The original implementation only committed lines in `flush()`, + so the buffer was always empty. Fixed: `write()` now detects the trailing + `\n` that marks a complete tracing event and commits immediately; `flush()` + is kept as a fallback for partial lines. + +- **Muse target UUID ignored on connect**: `connect_muse()` previously + scanned for the first Muse in range regardless of which device was paired. + In multi-headset environments the wrong device could be selected. The BLE + UUID from `ble:` targets is now used to filter `scan_all()` results + in the slow-path fallback. + +- **Muse connected but no EEG data streamed**: daemon Muse connect paths now + explicitly start the stream after BLE/GATT connect by calling + `MuseHandle::start(true, false)` and then best-effort + `request_device_info()`. Previously the session could report + "connected" while receiving zero samples (flat waveforms, CSV header only). + +- **Retry connect could silently no-op** when `status.target_name` was empty. + Daemon retry target resolution now uses paired-first policy and falls back + through: `target_id` → legacy `target_name` → preferred paired discovered + device → first paired device. + +- **Auto-connect fallback improved for continuous recording**: + when the default/previous target is unavailable, retry falls back to the + next paired device that is currently available. If no valid target is set + but a paired device is available, reconnect auto-selects that paired target. + +- **Continuous reconnect loop on startup**: + after an initial failed connect, auto-reconnect now keeps trying every + **3 seconds** (instead of stopping after one failure). The dashboard shows + the countdown timer, users can cancel reconnect, and can resume by pressing + connect/retry again. The cadence is now centralized in a single constant + (`AUTO_RECONNECT_CADENCE_SECS`) to keep runtime behavior/tests/docs aligned. + +- **Connecting target naming is now daemon-authoritative**: + `StatusResponse` gained canonical fields: + - `target_id` (stable device id like `ble:...`) + - `target_display_name` (human-readable paired name) + + Session control and routing paths (`start/switch/retry/spawn`) now set these + fields from paired metadata, instead of forcing UI heuristics. + +- **Potential daemon deadlock in connect status updates fixed**: + target-field resolution previously re-locked `status` while already holding + `status.lock()`. Resolution is now performed outside lock scopes. + +- **Pair-before-connect enforcement**: daemon start/switch session control now + rejects unpaired scanner targets (`ble:*`, `usb:*`, `wifi:*`, `cortex:*`, + etc.) with a clear user-facing error, preventing accidental connects to + non-paired devices. + +- **Defense-in-depth pairing guard**: session connect path also enforces the + same paired-only policy before attempting transport-specific connections. + +- **Settings → Devices paired list could appear empty** if paired devices were + not currently in the discovered list. Devices tab now reads authoritative + `status.paired_devices`, merges them into local rows, and keeps them visible + even when not actively advertising. + +- **Manual-connect hints were mixed into live discovered hardware** in + Settings → Devices. The built-in `neurosky` and + `brainvision:127.0.0.1:51244` rows are now rendered under a dedicated + **Manual connection hints** subsection instead of the normal discovered + hardware list. + +- **i18n cleanup**: the new manual-hints subsection uses translation keys + (`devices.manualHints`, `devices.manualHintsHint`) instead of hardcoded + English text. + +- **Recording-safety alerts** now run in both dashboard and backend poller: + - low battery warning notification (once per connected device), + - signal quality degradation warning when EEG quality drops from good to + sustained poor/no-signal during an active recording stream. + +- **Alert i18n**: dashboard alert titles/bodies (low battery + signal drop) + now use translation keys (`alerts.*`) instead of hardcoded English text. + +- **Dashboard pairing UX**: disconnected dashboard now shows nearby unpaired + compatible devices and supports one-click **Pair** (which immediately sets + default and starts connect) so EXG streaming can start without navigating to + Settings → Devices. + +- **Dashboard quick actions**: paired-device rows now include a dedicated + **Set default** action (without immediate connect) and an **open settings** + shortcut for advanced device management. + +- **Tray/dashboard connect labels now prefer canonical fields**: + runtime rendering uses `target_display_name` + `target_id` instead of legacy + `target_name` fallbacks. + +- **Runtime e2e schema snapshot updated** for the new daemon status fields + (`target_id`, `target_display_name`). + +- **Dead `hardware_version === "p21"` check in `isMuse2` detection**: + `"p21"` is the Classic Muse startup preset command string, not a + hardware version identifier. The Muse adapter never sets + `hardware_version`, so this condition was permanently `false` dead code. + Removed; `isMuse2` now relies solely on the advertising name containing + `"muse-2"` or `"muse 2"`. + +- **Unreachable `_ => {}` arm in session runner event loop**: after adding + the `DeviceEvent::Meta` handler, all seven `DeviceEvent` variants were + explicitly matched, making the catch-all arm unreachable (Rust warned + about it). Removed. + +- **Dashboard showed "DISCONNECTED" for daemon-managed sessions**: The Tauri + status mirror was overwriting the daemon's authoritative state. Introduced + `emit_status_from_daemon()` to prevent mirror-back when data originates + from the daemon. +- **Tray icon never updated for LSL sessions**: `refresh_tray()` was not + called from the daemon status poll path. +- **LSL connect button re-enabled instantly**: The button now stays disabled + with a "Connecting…" spinner until the daemon confirms the session state. +- **Band chart empty for >12 channels**: Fixed hardcoded `MAX_CH=12` buffer + limit in BandChart; now dynamically sized. +- **EEG waveform capped at 12 channels**: Frontend `EEG_CHANNELS` constant + updated from 12 to 32; chart buffers now sized from the actual channel + count prop. +- **`StatusResponse` missing device fields**: Expanded with channel names, + sample rate, signal quality, device identity, and capability flags so the + daemon can fully describe the connected device. + +- **`lsl_e2e` test ran for 30 s instead of ~8 s**: the receive loop had a + 30-second deadline that was always hit in full because the final 32 samples + (1 LSL chunk) were not delivered within the expected window. The test now + runs in ~8 s. + +- **`cargo test` broken by frontend test asserting dead behaviour**: the + Vitest test `"detects Muse S Athena by hw version"` called + `museImage("Muse-S", "p50")` and expected the Athena image path. After + removing the dead `hw === "p50"` check the test failed. The test is + rewritten to assert the CORRECT behaviour: `museImage("MuseS-F921")` (real + Athena advertising name) → Athena image; `museImage("Muse-S", "p50")` + (dead hw check) → Classic Muse S gen1 image. + +- **`cancel_retry` left `ble_scan_paused` set**: `control_cancel_retry()` + cancelled the session handle but did not clear `ble_scan_paused`. After + pressing Cancel mid-connection the BLE listener remained parked with its + scan stopped, so no BLE devices appeared in the Discovered list until the + next connection attempt or scanner restart. Fixed: `control_cancel_retry` + now unconditionally clears the flag. + +- **Paired devices showed as unpaired after daemon restart**: the scanner + tick's merge loop copied `is_paired` from `old` (previous in-memory device + list), which is empty on the first tick after a restart. Every + re-discovered device was therefore marked `is_paired = false`. Fixed: the + merge builds a `paired_ids` set from `status.paired_devices` (authoritative, + restored from disk) and checks it first; `old` is used only as a fallback + for the `is_preferred` flag, which is not persisted. + +- **`firmware_version` not synced from daemon to Tauri**: `apply_daemon_status()` + copied `hardware_version` from `StatusResponse` but silently skipped + `firmware_version`. After the new Meta handler sets it, Tauri now receives + it via the status poll. + +- **Dead `hardware_version === "p50"` Athena detection**: both + `devices-logic.ts` and `+page.svelte` checked `hw === "p50"` / + `hardware_version === "p50"` to identify the Muse S Athena firmware. + `p50` is a BLE preset command string, not a hardware version identifier, + and the Muse adapter never sets `hardware_version`. The condition was + permanently `false` dead code. Removed; the correct name-based detection + (`"muses"` substring in the advertising name) is now the sole check. + The corresponding Vitest test was updated to document the correct + behaviour (`"Muse-S"` + `hw="p50"` → Classic gen1 image; `"MuseS-F921"` + with no hw arg → Athena image). + +- **Non-atomic JSON writes could leave corrupt files**: `persist_paired_devices()` + previously used `std::fs::write()` directly for both `paired_devices.json` + and the `settings.json` sync. A crash or OS signal between truncation and + completion would leave a zero-byte or partially-written file. Both writes + now use `write_json_atomic()` (write to `.tmp` sibling, then `rename()`), + which is atomic on POSIX systems. + +- **Catch daemon HTTP negative paths in frontend client**: added Vitest coverage for bootstrap protocol mismatch, non-2xx error propagation, and `{ ok: false }` payload failures in `src/lib/daemon/http.ts`. +- **Verify invoke-proxy fallback behavior**: added runtime tests for `daemonInvoke` fallback to Tauri `invoke` on daemon HTTP failures and unknown commands. + +- **`cargo test` failed with 37+ compile errors** (invisible to + `cargo check -p skill-daemon` because the test profile compiles + `skill-iroh` as a direct dependency): + + - **`skill-iroh`** (`auth.rs`, `commands.rs`, `scope.rs`, `tunnel.rs`, + `device_receiver.rs`, `device_proto.rs`): all functions returning + `anyhow::Result` used `ok_or_else(|| "…".to_string())?`, + `Err(format!(…))`, and `.context(…)` without `use anyhow::Context`. + `String` does not implement `std::error::Error` so `?` could not convert + it. Fixed by replacing all string-error patterns with + `anyhow::anyhow!(…)` / `anyhow::bail!`, adding + `use anyhow::Context as _` where `.context()` is used, and changing + `Ok::<(), String>(())` closures in `tunnel.rs` to + `Ok::<(), anyhow::Error>(())`. + + - **`skill-daemon/src/session/connect.rs`**: same `ok_or("…")?` and + `map_err(|e| format!(…))?` patterns throughout all `connect_*` + functions. Replaced with `anyhow` equivalents. + + - **`skill-daemon/src/session_runner.rs`**: test assertions called + `.contains("…")` on `anyhow::Error` (which has no such method). + Updated to `.to_string().contains("…")`. + + - **`skill-daemon/src/service_installer.rs`**: `#[allow(unreachable_code)]` + applied to `anyhow::bail!` macro invocations (attributes don't apply to + macros); caused `unused_attributes` warnings and `unreachable_expression` + on macOS where the preceding `return` always fires. Fixed by gating + the fallback `bail!` behind + `#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]`. + + - **`skill-iroh` / `iroh_test_client` / `iroh-example-client` test and + example code**: `serde_json::json!({"error": e})` where `e` is + `anyhow::Error` (not `Serialize`); `err.contains(…)` on `anyhow::Error`. + Fixed with `.to_string()` at each call site. + + - **`skill-headless/src/engine.rs`**: used `anyhow::Result` in the + `ExternalRendererFn` type and `external_fetch_page` without `anyhow` + in `Cargo.toml`. Added `anyhow = { workspace = true }` to + `skill-headless` and `use anyhow;` in `engine.rs`. + + - **`skill-lsl/src/virtual_source.rs`**: `use anyhow::Context as _` was + placed inside the module doc-comment block, making it an inner doc + comment rather than a use declaration. Moved above the doc block. + +- **`setup_app` return type incompatible with Tauri's `.setup()` closure**: + the function returned `anyhow::Result<()>` but Tauri expects + `Result<(), Box>`. Kept the function signature as + `anyhow::Result<()>` (preserving `.context()` chaining throughout the + body) and adapted the single call site with `.map_err(Into::into)`. + +- **Protect EXG route extraction with tests**: added smoke + edge coverage for extracted EXG model routes (config/status/catalog/reembed/rebuild/estimate, duplicate-download rejection, cancel flag handling) to prevent regressions during modularization. +- **Harden runtime/dispatch/connect contracts**: added command-matrix coverage in `cmd_dispatch`, deterministic and overlap checks for connect route selection, and additional LLM runtime state transition tests to improve reliability under refactors. + +### Refactor + +- **`is_known_eeg_ble_name` unit tests** (`skill-daemon`): 11 cases covering + all accepted EEG device families (Muse, Ganglion, MW75, Hermes, Mendi, + IDUN, BrainBit, Unicorn) and 5 rejection cases (JBL, Apple Watch, iPhone, + AirPods, empty string, anonymous UUID names). `tempfile` added as a + `[dev-dependencies]` entry for `skill-daemon`. + +- **`write_json_atomic` round-trip test**: verifies the file is written with + the correct content and no stray `.tmp` file is left behind. + +- **Central `apply_daemon_status()` helper**: Replaced 4 copy-paste + cherry-pick sites with a single function that maps all `StatusResponse` + fields to `DeviceStatus`. +- **`StatusResponse::clear_device()`**: Clean disconnect reset instead of + manual field clearing. +- **BLE-centric i18n strings**: Updated to be device-agnostic (BCI headset + → device, added LSL references). + +- **LSL E2E test redesigned for correctness and speed**: + + - **Root cause of 30-second test duration**: the rlsl TCP session thread + checks `shutdown` at the *top* of its loop; `Drop for StreamOutlet` sets + `shutdown = true` and pushes a sentinel, which causes the thread to exit + immediately and discard any samples still in `chunk_buf` — reliably losing + the last 32-sample chunk on every run. The outlet is now kept alive in + the push thread until the async receive loop finishes by sending it through + a rendezvous channel; the async side drops it only after the drain pass. + + - **Receive deadline tightened**: 30 s → 8 s (5 s data + 3 s headroom). + The old deadline was long enough to mask all timing issues silently. + + - **Sample-loss threshold tightened**: minimum accepted sample count raised + from `TOTAL_SAMPLES / 2` (50%) to `TOTAL_SAMPLES * 95 / 100` (95%), so a + lost chunk now actually fails the test. + + - **Post-loop drain pass**: a 500 ms drain loop after the timed receive + window collects any EEG frames that landed in the adapter channel exactly + as the deadline fired, ensuring they are counted in the final tally. + +- **Standardise error handling on `anyhow` across the workspace**: all + internal library functions that previously returned `Result` + migrated to `anyhow::Result`, enabling `?` propagation, structured + context via `.context()`, and `anyhow::bail!` at error sites. Affected + crates: `skill-iroh` (auth, tunnel, scope, device-proto, device-receiver, + commands), `skill-daemon` (service installer, session runner, `Pipeline::open`, + all `connect_*` device functions, `routes/labels::open_labels_db`, + `cortex_probe_headsets`). Tauri `#[command]` handlers and WebSocket + dispatch functions in `cmd_dispatch.rs` intentionally retain + `Result` at their serialisation boundaries. + +- **Workspace-pinned `anyhow` in all crates**: `crates/skill-headless` and + `crates/skill-lsl` were missing the `anyhow` dependency entirely. Added + `anyhow = { workspace = true }` so all crates share the same resolved + version. + +- **Modularize daemon settings routes by domain**: split `crates/skill-daemon/src/routes/settings.rs` into focused modules (`settings_io`, `settings_lsl`, `settings_llm`, `settings_llm_runtime`, `settings_llm_chat`, `settings_exg`, `settings_hooks_activity`) while preserving existing route paths and handler behavior. +- **Compose settings router from subrouters**: introduced grouped route composition (`exg_routes`, `llm_routes`, `lsl_routes`) to reduce `settings.rs` complexity and make route ownership auditable. +- **Simplify command dispatch structure**: refactored `crates/skill-daemon/src/cmd_dispatch.rs` to use grouped family dispatch for hooks/sleep-schedule/dnd/iroh/llm commands, reducing top-level `match` size. +- **Make device connector routing table-driven**: updated `session/connect.rs` to use an explicit `ConnectRoute` selector path and added route-selection coverage for aliases/prefixes. + +### Build + +- **Ignore dev log artifacts**: added `logs/` to `.gitignore` so persisted TUI session logs are not accidentally committed. + +- **Add audit script command**: added `npm run audit:daemon-routes` to regenerate the Tauri→daemon route audit report. + +### UI + +- **Custom dev TUI replaces tmux for `npm run tauri dev`**: removed tmux-based pane orchestration and added a built-in terminal UI (`scripts/tauri-dev-tui.js`) that shows a pink NeuroSkill banner, daemon logs on the left, and Tauri logs on the right. + +- **Improved TUI usability**: added active-pane highlighting, footer status, per-pane follow mode, reduced flicker via throttled rendering, and richer key help (`Tab`, `j/k`, arrows, `PgUp/PgDn`, `g/G`, `f`, `?`, `q`). + +- **Mouse wheel scroll support**: enabled SGR mouse tracking and mapped wheel events to scroll the pane under the cursor. + +### Server + +- **Harden Tauri→daemon API contracts end-to-end**: added runtime contract tests for `src-tauri/src/daemon_cmds.rs` that verify method/path/auth-header correctness across sync, control/LLM, and async proxy routes against a mock HTTP daemon. +- **Stabilize daemon contract test harness**: unified test locking for env-var/token-path based tests and made mock servers tolerate TCP readiness probes so parallel test execution no longer causes flaky failures. + +- **Keep Tauri daemon proxy behavior stable during cleanup**: reduced boilerplate in `src-tauri/src/daemon_cmds.rs` by adding shared async proxy helpers while preserving existing endpoint contracts. + +### Docs + +- **Add command-to-route coverage audit**: introduced `scripts/audit-daemon-routes.js` and generated `docs/testing/tauri-daemon-route-audit.md` with full `daemonInvoke` command → method → path matrix and summary counts. + +## [0.0.86] — 2026-04-06 + +### Features + +- **Daemon CLI command dispatch**: universal JSON command dispatcher mapping ~70 CLI commands to daemon REST handler logic. `POST /` root tunnel and `POST /v1/cmd` authenticated endpoints. WebSocket handler upgraded to bidirectional — dispatches incoming JSON commands alongside broadcasting events. +- **CLI daemon connectivity**: auto-discovers daemon port 18444, loads auth token from `~/.config/skill/daemon/auth.token`, includes Bearer token in WS and HTTP requests. +- **Full session recording pipeline**: CSV and Parquet recording via `SessionWriter`, `BandAnalyzer` DSP computing band power at ~4 Hz (delta through gamma, FAA, TAR, coherence, entropy, 30+ derived metrics), `EegBands` WS events broadcast, session metadata JSON sidecar, epoch metrics stored in `eeg.sqlite`. +- **EXG embedding pipeline**: sliding-window 5s epoch accumulator with resampling, per-day HNSW + SQLite store, background embed worker thread with 9 encoder backends (ZUNA, LUNA, REVE, OSF, SleepFM, SleepLM, ST-EEGFormer, TRIBEv2, NeuroRVQ). All enabled by default via `embed-exg` feature flag. +- **Hook triggers**: `HookMatcher` with fastembed BGE-small-en-v1.5 for keyword→vector, label index HNSW search, cosine distance against live EEG embeddings, scenario filtering, rate limiting, audit log in `hooks.sqlite`. +- **LLM streaming over WebSocket**: incremental delta tokens via mpsc bridge, protocol matches CLI expectations (session → delta* → done). LLM inference enabled by default. +- **Generic adapter session runner**: single `run_adapter_session` function drives any `Box` through the full daemon pipeline. Replaced device-specific event loops. +- **Enriched band snapshots**: focus, relaxation, engagement composite scores computed from raw band power data, matching the old Tauri session runner formulas. +- **Parquet storage support**: `SessionWriter` dispatches to CSV, Parquet, or both based on user's `storage_format` setting. IMU frames also recorded. + +- **NeuroField Q21 support**: 20-channel FDA-approved EEG amplifier via PCAN-USB (CAN bus). Scanner probes online PCAN interfaces, session runner with blocking reader thread, standard 10-20 electrode names (F7, T3, T4, T5, T6, Cz, Fz, Pz, F3, C4, C3, P4, P3, O2, O1, F8, F4, Fp1, Fp2, HR). Crate: `neurofield 0.0.1`. +- **BrainBit support**: BrainBit, BrainBit 2, Pro, Flex 4/8 EEG headbands via NeuroSDK2 BLE. 4 channels (O1, O2, T3, T4) at 250 Hz. Callback-based streaming via `on_signal()`. Crate: `brainbit 0.0.1`. +- **g.tec Unicorn Hybrid Black support**: 8-channel EEG headset via Unicorn C API (BLE). 250 Hz, blocking `get_single_scan()` reader thread. Crate: `gtec 0.0.2`. +- **Restored all BLE devices**: Muse, MW75 Neuro, Hermes V1, IDUN Guardian, Mendi fNIRS — all re-wired through the generic adapter session runner with full pipeline (CSV/Parquet, DSP, embeddings, hooks). +- **Restored Emotiv**: EPOC X, Insight, Flex, MN8 via Cortex WebSocket API. +- **Restored Cognionics CGX**: Quick-20r and other CGX headsets via USB serial. +- **LSL stream sessions**: `connect_lsl()` resolves EEG streams, creates `LslAdapter`, feeds into generic pipeline. Discovery was working; session recording was broken and is now fixed. + +### Bugfixes + +- **Windows COM port ≥ 10**: added `\\.\COMxx` prefix normalization for COM10+ ports that otherwise fail with "file not found". +- **FTDI dongle auto-detection**: 3-pass serial port detection (VID/PID → product/manufacturer string → heuristic fallback) catches dongles that Windows reports without USB metadata. +- **Hot-plug retry**: 3 attempts with 1.5s/3s backoff delays when the serial port is temporarily locked after USB replug. +- **Device ID → session start**: `start_session` now accepts `usb:COM3`, `cgx:*`, `neurofield:*`, `brainbit:*`, `gtec:*`, `lsl:*` device IDs and routes to the correct connect function. +- **LLM inference disabled by default**: daemon `Cargo.toml` had `default = []` — all `#[cfg(feature = "llm")]` code was dead. Fixed: `default = ["llm", "embed-exg"]`. +- **LSL sessions broken**: `lsl:` targets were not handled in `connect_device`. Now wired through `connect_lsl()` with stream resolution and `LslAdapter`. + +### Docs + +- **DEVICES.md**: complete device support matrix — 13 device families, 19 hardware variants, 3 virtual/network sources. Transport summary, platform support table, device ID format reference, guide for adding new devices. + +## [0.0.85] — 2026-04-03 + +### Docs + +- **Add changelog fragment scaffold**: create an unreleased entry so `changes/unreleased` is no longer empty. + +## [0.0.84] — 2026-04-03 + +### Bugfixes + +- **Windows CI lockfile stability**: pinned `Cargo.lock` to prevent intermittent Windows CI breakage. + +### Docs + +- **Release archive correction**: rebuilt archived release notes to replace previously empty entries with history-derived notes. + +## [0.0.83] — 2026-04-03 + +### Build + +- **Prebuilt llama fallback in CI/release**: added prebuilt llama artifact usage with safe source-build fallback in CI and mac release flows. + +### Bugfixes + +- **iroh-remote reconnect fixes**: corrected reconnect routing and prevented unwanted BLE reconnect loops during iroh-remote session startup. + +## [0.0.82] — 2026-04-02 + +### Build + +- **Linux release packaging fix**: stopped requiring removed espeak-ng resource bundle in Linux release path. + +### CLI + +- **Changed-crates detection hardening**: fixed metadata parsing with safer fallback behavior for crate change detection. + +## [0.0.81] — 2026-04-02 + +### Features + +- **EXG/LLM model catalog expansion**: added NeuroRVQ integration and restored Bonsai model options with related inference controls. +- **LSL and EXG UX updates**: added LSL idle-timeout configurability, virtual test source controls, and channel inspector/stream handling improvements. +- **Inference/device settings growth**: expanded CPU/GPU preference and runtime tuning paths for llama-cpp-based inference. + +### Bugfixes + +- **Updater packaging fixes**: corrected ZIP compression behavior across Windows/desktop updater flows. +- **Location + backend stability**: fixed location-enable hangs and improved Neutts backend init/lifetime reliability. +- **Dependency/runtime fixes**: resolved llama-cpp source/version mismatches and related runtime integration issues. + +### Dependencies + +- **Dependency alignment**: moved to newer llama-cpp stack revisions and fixed metadata/licensing fields required by dependency checks. + +### Docs + +- **Backfilled release notes**: reconstructed from Git history because archived fragment entries were missing for this version. + +## [0.0.80] — 2026-03-30 + +### Build + +- **Release metadata update**: version/tag release step (`v0.0.80`) with no additional feature-level deltas beyond changes already landed before tagging. + +### Docs + +- **Backfilled release notes**: reconstructed from Git history because archived fragment entries were missing for this version. + +## [0.0.79] — 2026-03-30 + +### Test + +- **Playwright E2E expansion**: added broad E2E coverage for static pages plus `/chat`, `/settings`, `/search`, `/compare`, calibration/labels/API flows. + +### Build + +- **CI release workflow cleanup**: removed stale espeak C build steps and fixed invalid empty `env:` blocks in release-linux workflow. + +### Docs + +- **Backfilled release notes**: reconstructed from Git history because archived fragment entries were missing for this version. + +## [0.0.78] — 2026-03-30 + +### Features + +- **Location permission + gating flow**: added `location_enabled` setting with permission handling and LLM tool gating. +- **Health diagnostics**: introduced deeper `npm run health`/session integrity inspection support. + +### Build + +- **TTS/espeak build simplification**: removed legacy espeak C build infrastructure and aligned build scripts with newer Neutts/Kittentts flow. + +### Bugfixes + +- **Stability pass**: resolved clippy + svelte-check issues and fixed dependabot/CI breakages impacting release readiness. + +### Docs + +- **Backfilled release notes**: reconstructed from Git history because archived fragment entries were missing for this version. + +## [0.0.77] — 2026-03-30 + +### Features + +- **History map pipeline**: added unified history aggregation for GPS/HNSW/embeddings and PMTiles-based map rendering in history views. +- **GPS support path hardening**: enabled GPS sampling pipeline with feature gating and improved location tooling docs/CLI wiring. + +### Bugfixes + +- **History reliability fixes**: unified session listing/day-boundary behavior and fixed stale cache/deserialization issues causing empty or inconsistent history views. + +### Docs + +- **Backfilled release notes**: reconstructed from Git history because archived fragment entries were missing for this version. + +## [0.0.76] — 2026-03-30 +### Features + +- **Oura Ring V2 integration**: New `skill-oura` crate fetches sleep, activity, readiness, heart rate, SpO2, workouts, and mindfulness data from the Oura Cloud API and stores it in the unified `health.sqlite` pipeline alongside Apple HealthKit data. Includes `oura_sync` and `oura_status` WS/HTTP commands, CLI `oura` command with `sync` and `status` subcommands, REST endpoints (`/v1/oura/sync`, `/v1/oura/status`), token management via system keychain, Settings UI with token input + Sync Now button, history view health overlay card showing Oura sleep/readiness/activity scores, 11 Rust tests with full round-trip integration test, E2E tests in test.ts, i18n in 6 languages, supported devices catalog entry, and comprehensive SKILL.md documentation. + +## [0.0.75] — 2026-03-29 + +### Features + +- **187 new Rust tests** across 17 crates covering: session history listing/deletion/stats, CSV timestamp parsing, screenshot store (search, OCR, embeddings, pagination, app grouping), bash/path safety checks, tool output truncation, LLM argument coercion (bool/number/string/null/object/array), tool-call extraction and stripping, path resolution and retry logic, DOT graph generation, LLM config serde, screenshot config interval logic, catalog persistence, and tool orchestration (stream sanitizer, result summarization, context condensation). + +### Bugfixes + +- **Fixed iroh LSL sink hardcoded descriptor**: `IrohLslAdapter` was hardcoding 4 channels at 256 Hz regardless of the actual remote stream. The DSP pipeline, CSV writer, and embedding encoder now use the correct channel count, sample rate, and channel labels discovered from the connected source. Added 120-second timeout on stream resolution (was infinite loop) and two-phase start API so the endpoint ID is available immediately. +- **Fixed `skill-data` screenshot_store tests**: added missing `source`, `chat_session_id`, and `caption` fields to test helper after struct update. +- **Fixed `skill-jobs` queue test**: captured second `submit()` return value and used correct closure variable in `sequential_execution` test. +- **Fixed `skill-llm` E2E test model selection**: `best_test_model()` now filters out mmproj filenames, preventing selection of a vision projector (0.54 GB) instead of the actual language model (1.16 GB). +- **Fixed `skill-lsl` test warnings**: removed unused import and shadowed variable bindings. + +### Refactor + +- **Exported `coerce_value`** from `skill-tools::parse` for external test access. +- **Added `rustls` workspace dep** with `ring` backend to avoid `aws-lc-rs` where possible. + +### Build + +- **Tiered Rust test runner** (`scripts/test-fast.sh`): split workspace tests into 3 tiers by compilation cost — tier 1 runs ~400 tests in 5 s (warm) vs 80 s for the full workspace. Added `npm run test:rust` and `npm run test:rust:all` scripts. +- **Disabled `jsonschema` default features** in `skill-tools`: removed transitive `reqwest` → `rustls` → `aws-lc-sys` dependency (45 s build) that was only used for remote JSON schema fetching we never use. + +### Docs + +- **Updated `CONTRIBUTING.md`** with tiered test runner documentation and timing table. +- **Added `docs/TEST-COVERAGE.md`**: detailed per-crate coverage analysis with prioritized gap list. + +## [0.0.72] — 2026-03-25 + +### Features + +- **Calendar event fetching**: New `skill-calendar` crate adds cross-platform OS calendar support. + - **macOS**: Reads all calendars via Apple EventKit (`EKEventStore`) using Objective-C FFI — covers iCloud, Google, Exchange, and local calendars synced to Calendar.app. Handles the macOS 14+ `requestFullAccessToEventsWithCompletion:` API (requires `NSCalendarsFullAccessUsageDescription`) with fallback to the legacy API on macOS 10.15–13. + - **Linux**: Scans XDG locations for `.ics` files: GNOME Calendar, Evolution, KOrganizer, Thunderbird Lightning (targeted `calendar-data/` subdir only — not the full mail profile), and `~/Calendars/`. + - **Windows**: Scans Outlook / Windows Calendar paths under `%APPDATA%`, `%LOCALAPPDATA%`, and UWP package directories for `.ics` files. + - Shared RFC 5545 iCal parser: line folding (CRLF/LF/tab), `VTIMEZONE` UTC-offset extraction, `VALUE=DATE` all-day events, UTC (`Z`) timestamps, iCal escape sequences (`\n`, `\,`, `\;`, `\\`), `X-WR-CALNAME` (Google Calendar name at VCALENDAR level), recurrence rule passthrough. 46 unit tests. + - **WS commands**: `calendar_events` (fetch by range), `calendar_status` (auth state + platform), `calendar_request_permission` (macOS system dialog). Both potentially-blocking commands run via `spawn_blocking`. + - **HTTP REST**: `POST /v1/calendar/events`, `GET /v1/calendar/status`, `POST /v1/calendar/permission` (+ unversioned aliases). + - **CLI**: `calendar [--start --end]`, `calendar status`, `calendar permission`. + - **LLM tools**: `calendar_events`, `calendar_status`, `calendar_request_permission` in the `skill` tool enum and `is_skill_api_command` registry. `"calendar"` alias in `resolve_skill_alias`. + - **Skill markdown**: `skills/skills/neuroskill-calendar/SKILL.md` with LLM tool examples, timestamp arithmetic, common query patterns, and access troubleshooting. + - **macOS entitlements**: `com.apple.security.personal-information.calendars` added to `entitlements.plist`; `NSCalendarsUsageDescription` and `NSCalendarsFullAccessUsageDescription` added to `Info.plist`. + - `calendar_events`, `calendar_status`, `calendar_request_permission` added to `skill-router::COMMANDS` public registry. + +### Bugfixes + +- **Calendar Linux dedup (critical)**: `linux.rs` two-pass deduplication silently dropped every event with a non-empty UID — the first loop pre-inserted all UIDs into `seen`, causing the second loop's `!seen.contains` check to always be false. Both `linux.rs` and `windows.rs` now use single-pass atomic check-and-insert. Anonymous-event keys use NUL separator (`"\0"`) to prevent hash collisions. +- **Calendar `X-WR-CALNAME` scope**: Google Calendar exports place the calendar name at the `VCALENDAR` level, not inside `VEVENT`. The property was only scanned inside `VEVENT` and therefore never populated. Fixed with a top-level pass in `parse_ical()`. +- **Calendar async blocking**: `calendar_events` and `calendar_request_permission` called blocking ObjC EventKit code directly on the tokio async executor thread (up to 30 s for the macOS permission dialog). Both are now `async` and dispatch to `spawn_blocking`. +- **Calendar macOS 14 privacy key**: `NSCalendarsFullAccessUsageDescription` was missing; without it `requestFullAccessToEventsWithCompletion:` silently fails on Sonoma/macOS 14+. +- **Calendar Thunderbird scan**: `~/.thunderbird` root was searched to depth 6, walking the entire mail profile (potentially GB of data). Now only `~/.thunderbird//calendar-data/` is scanned. +- **Calendar EventKit error detection**: `macos.rs` used `json_str.contains("\"error\"")` to detect access-denied responses — a false positive for any event whose title or description contains the word `"error"`. Replaced with proper JSON type-based dispatch: `Array` → success, `Object` with `"error"` key → propagate error string. +- **Calendar EventKit write-only access**: When the user chose "Add Events Only" (`EKAuthorizationStatusWriteOnly`, status 4), the fetch proceeded and returned an empty array instead of an error. Now returns `{"error":"calendar_write_only_access"}` so clients can prompt the user to grant full read access. +- **LLM e2e test non-exhaustive match**: `llm_e2e.rs` match on `ToolEvent` was non-exhaustive after `RoundComplete { .. }` was added to the enum; added the missing arm. +- **`skill-calendar` unused dependency**: `anyhow` was declared in `Cargo.toml` but never used in the crate; removed. + +- **Cleared all Biome lint errors** across `src/` and `scripts/`: fixed `noNonNullAssertion`, `noExplicitAny`, `useIterableCallbackReturn`, `noAssignInExpressions`, `useImportType`, and `noUnusedFunctionParameters` in ~50 files using safe rewrites or targeted `biome-ignore` suppressions with justification comments. +- **Restored blocking Biome lint in CI**: `Biome lint` step in `.github/workflows/ci.yml` is now a hard failure again (0 errors, 0 warnings). +- **Suppressed false-positive `useImportType` warnings for `.svelte` files**: added a `biome.json` override so Biome no longer flags Svelte component runtime imports as type-only — converting them to `import type` breaks `svelte-check`. +- **Fixed incorrect auto-conversion of Svelte component imports**: reverted `import type` from bits-ui and Svelte component imports that must remain runtime imports (`PromptLibrary`, `ChatInputBar`, `ChatMessageList`, `ChatSidebar`, `DialogPrimitive`, `ProgressPrimitive`, `SeparatorPrimitive`, `DialogPortal`). + +- **Security audit CI**: Fixed `cargo deny` failing due to breaking schema changes in cargo-deny 0.16+ (used by `EmbarkStudios/cargo-deny-action@v2`). Removed deprecated `[advisories]` fields (`vulnerability`, `notice`), updated `unmaintained` from a lint level to the new scope value (`"workspace"`), removed deprecated `[licenses]` fields (`unlicensed`, `copyleft`), added missing allowed licenses (`Apache-2.0 WITH LLVM-exception`, `MIT-0`, `Unlicense`, `CDLA-Permissive-2.0`, `NCSA`, `LicenseRef-AI100`), fixed workspace crate exception name (`neuroskill` → `skill`) and SPDX identifier (`GPL-3.0` → `GPL-3.0-only`), added `clarify` entries for `exg`/`exg-luna` (AI100 license), `unescaper` (non-SPDX `GPL-3.0/MIT`), and ignored RUSTSEC-2024-0415 (gtk via Tauri, not directly upgradeable). + +- **Fix cargo-deny CI failure**: Updated `deny.toml` for cargo-deny v0.19+ breaking schema changes — removed deprecated fields (`vulnerability`, `notice`, `unlicensed`, `copyleft`), fixed `unmaintained` scope value, corrected `GPL-3.0` → `GPL-3.0-only` in license exceptions, added missing allowed licenses (`MIT-0`, `Apache-2.0 WITH LLVM-exception`, `Unlicense`, `NCSA`, `CDLA-Permissive-2.0`, `LicenseRef-AI100`), added clarification entries for `exg`/`exg-luna` custom-licensed crates, added exceptions for new crates (`skill-calendar`, `hermes-ble`, `mw75`), and ignored the gtk-rs GTK3 unmaintained advisory (RUSTSEC-2024-0415). + +- **jsonschema 0.45 compatibility in tool argument validation**: updated `skill-tools` validation error path extraction to use `ValidationError::instance_path()` so the workspace compiles and validation errors still report schema paths correctly after the dependency upgrade. + +### Build + +- **Faster local hooks**: enabled automatic `sccache` usage in `.githooks/pre-commit` and `.githooks/pre-push` when available, including C/C++ launcher integration for cmake-based crates. +- **Docs-only fast path**: both local hooks now skip expensive frontend/Rust checks for docs/changelog-only changes. + +- **Faster pre-commit checks**: updated `.githooks/pre-commit` to run frontend checks only on changed files (`biome check` + `vitest related`) instead of full project-wide frontend checks on every commit. + +- **Faster pre-push checks**: updated `.githooks/pre-push` to run changed-files scoped frontend and Rust checks by default. +- **Optional full gate**: full pre-push validation can still be forced with `PREPUSH_FULL=1 git push`. + +- **CI frontend lint stability**: switched `Biome lint` in `.github/workflows/ci.yml` back to advisory (`continue-on-error: true`) so existing lint debt no longer hard-fails the frontend job while cleanup is ongoing. + +### UI + +- **Mixed browser User-Agent pool**: replaced the outdated and browser-mixed UA list in `skill-tools` with current-version strings spanning Chrome 133–134, Firefox 128 ESR / 135–136, Safari 17–18, and Edge 133–134 across Windows, macOS, and Linux. + +### i18n + +- **sync-i18n lint fix**: removed a non-null assertion in `scripts/sync-i18n.ts` when collecting extra locale keys, using an explicit `undefined` guard instead. + +### Dependencies + +- **Align Arrow stack in `skill-data`**: upgraded `parquet` and `arrow-array` to `58` alongside `arrow-schema` so Parquet writer schemas and `RecordBatch` types use a consistent Arrow version and compile cleanly. + +- **Align Arrow stack to v58**: bumped `parquet`, `arrow-array`, and `arrow-schema` in `skill-data` all to version 58, and updated Parquet EEG column casting in `src-tauri` to use `downcast_ref::()` (replacing the removed `as_primitive_opt`). + +- **Block wgpu Dependabot upgrades**: added `ignore` rule in `.github/dependabot.yml` for `wgpu >= 27`. The workspace pins wgpu to 26.x via `burn-wgpu`, `gpu-fft`, `zuna-rs`, and `luna-rs`; bumping wgpu independently causes type mismatches in the `WgpuSetup` pipeline. The whole Burn/GPU stack must be upgraded together. + +- **ureq 3 migration**: updated all ureq usages across `skill-tools`, `skill-llm`, `skill-exg`, `skill-screenshots`, and `skill-skills` for the ureq 2→3 breaking API changes: + - `AgentBuilder::new()...build()` → `Agent::config_builder()...build().into()` + - `.timeout(d)` → `.timeout_global(Some(d))`, `.timeout_read(d)` → `.timeout_recv_body(Some(d))` + - `.set("K", v)` → `.header("K", v)`, `.send_string(s)` → `.send(s)` + - `resp.into_string()` → `resp.into_body().read_to_string()` + - `resp.into_json()` → `resp.into_body().read_json()` + - `resp.into_reader()` → `resp.into_body().into_reader()` + - `resp.status()` → `resp.status().as_u16()` (now returns `StatusCode`) + - `resp.header("K")` → `resp.headers().get("K").and_then(|v| v.to_str().ok())` + - `Error::Status(code, _)` → `Error::StatusCode(code)` + +## [0.0.71] — 2026-03-25 + +### Bugfixes + +- **Latest CI failures resolved locally**: fixed frontend Biome formatting drift and removed a Windows-only unnecessary raw-pointer cast in `src-tauri/src/skill_log.rs`. +- **Hook shell compatibility**: replaced `mapfile` usage in `.githooks/pre-commit` with a bash-3-compatible dedupe loop so commits work on macOS default bash. + +- **Auto-bootstrap chat LLM on first start**: when starting the chat server with no downloaded text model, the app now downloads the smallest `LFM2.5-VL 1.6B` variant first and then starts the server once download completes. +- **Onboarding LLM priority aligned to bootstrap**: first-run model targeting now prefers the smallest `LFM2.5-VL 1.6B` option before other recommendations. + +- **CI frontend format failures**: formatted Svelte files flagged by Biome in the `frontend-check` job (`LlmTab`, `ScreenshotsTab`, `LlmInferenceSection`, `ScreenshotPerformanceSection`). +- **Windows clippy failure**: removed an unnecessary raw-pointer cast in `src-tauri/src/skill_log.rs` when calling `SetStdHandle`, fixing `clippy::unnecessary_cast` under `-D warnings`. + +- **Default LLM switched to LFM2.5 1.2B Instruct**: first-run chat/bootstrap now prefers `LFM2.5 1.2B Instruct` (Q4_K_M first) instead of `LFM2.5-VL 1.6B`. +- **Default activation on bootstrap**: when no local text model exists, the selected default is set as active immediately and downloaded automatically before server start. + +- **Hard-default LLM activation**: starting the LLM server now always prefers `LFM2.5 1.2B Instruct` as the active text model. +- **Default model enforcement with existing downloads**: even if other models are already present, the app ensures the default `LFM2.5 1.2B Instruct` is selected and downloaded before starting. + +- **LLM autolaunch memory guard**: before auto-downloading/auto-launching the default `LFM2.5 1.2B Instruct` model, the backend now checks hardware memory fit and blocks autolaunch when RAM/VRAM is insufficient. +- **Clear startup feedback for low-memory systems**: when memory is too tight, startup now returns a descriptive stopped-state error with required vs available memory. + +- **`skill-eeg` test build with GPU feature**: gated CPU-FFT property tests behind `not(feature = "gpu")` so workspace library test runs do not fail when `gpu` is enabled. + +- **Hide Windows DND helper console windows**: run `reg query` / `reg add` with `CREATE_NO_WINDOW` so background DND polling no longer flashes a terminal window every 5 seconds on Windows. + +- **Add stable Windows log mirror**: create and append to `%LOCALAPPDATA%\NeuroSkill\latest.log` as a fallback mirror. If date-based session log creation fails, stderr is redirected to `latest.log` so logs are still captured. + +- **Create Windows session logs reliably**: write an explicit startup log line after logger initialization so `log_.txt` is created immediately in `%LOCALAPPDATA%\NeuroSkill\YYYYMMDD`. +- **Improve Windows quit confirmation ownership**: show the quit dialog on the caller thread (instead of a detached worker) with a parent window, improving native ownership/icon behavior. + +- **Capture all stderr logs on Windows**: route process `stderr` to the session log file during startup (`SetStdHandle`) and keep a file-handle fallback sink so both `skill_log!` output and generic `eprintln!` logs are written to `%LOCALAPPDATA%\NeuroSkill\YYYYMMDD\log_.txt`. + +- **Added integration tests for settings/history contracts**: introduced new integration tests for `skill-settings` defaults/path behavior and `skill-history` metrics/PPG sidecar resolution (`exg_` and legacy `muse_` paths). + +- **Added `skill-history` session-prefix integration coverage**: new integration tests verify that session day listing and per-day session loading accept both modern `exg_` and legacy `muse_` prefixes for JSON sidecars and orphan CSV files. + +- **Added router integration tests**: new `skill-router` integration tests cover UMAP cache path/store/load round-trips and inclusive epoch-label matching behavior. +- **Added label-index integration tests**: new `skill-label-index` integration tests verify empty-window EEG mean behavior and startup index initialization via `LabelIndexState::load()`. + +- **Added `skill-skills` integration tests**: new tests cover explicit-path skill loading, required-description validation diagnostics, and system-prompt formatting that omits `disable-model-invocation` skills. + +### Refactor + +- **Parse module test split**: moved large inline tests from `crates/skill-tools/src/parse/mod.rs` into dedicated `crates/skill-tools/src/parse/tests.rs` and kept `mod.rs` focused on module exports. + +- **Simplified `LlmTab` state surface**: removed local `showAdvanced`, `apiKeyVisible`, and `ctxSizeInput` ownership from `LlmTab` and delegated these UI concerns to `LlmInferenceSection`, while keeping config persistence centralized via `saveConfig()`. + +- **Slimmed down `LlmTab` orchestration layer**: `LlmTab` now delegates server, model picker, inference, and log rendering to focused child components and keeps only shared state/event wiring. + +- **Reduced `LlmTab` UI state surface**: removed log-view-local state (`logFilter`, `logSearch`, `logAutoScroll`, scroll element management) from `LlmTab` and delegated it to `LlmServerLogSection` while keeping event-driven log ingestion in the parent. + +- **Simplified capture-setting updates in `ScreenshotsTab`**: centralized capture field patching via a small `onUpdate` bridge passed to `ScreenshotCaptureSettingsSection`, including recommended-size adoption on backend/model changes. + +- **Further slimmed `ScreenshotsTab` composition**: `ScreenshotsTab` now delegates toggle, OCR, and performance rendering to focused child components while keeping shared state and persistence logic in one place. + +- **Reduced `ScreenshotsTab` template complexity**: removed in-file chart formatting/render helpers from `ScreenshotsTab` and delegated rendering to `ScreenshotPerformanceSection`, keeping tab state/update logic focused. + +- **Further streamlined `ScreenshotsTab` composition**: `ScreenshotsTab` now delegates permission and privacy presentation to focused child components, keeping the tab file centered on state orchestration. + +- **Further reduced `ScreenshotsTab` orchestration scope**: removed local ETA formatting/render logic from `ScreenshotsTab` and delegated re-embed presentation details to `ScreenshotReembedSection`. + +- **Simplified ToolsTab composition**: `ToolsTab` now composes `SuggestSkillCta`, `AgentSkillsSection`, and `SkillsRefreshSection`, reducing in-file template complexity and local state surface. + +- **Reduced `ToolsTab` to orchestration composition**: `ToolsTab` now delegates chat-tools rendering to `ChatToolsSection` and keeps only state loading/saving plus skill-refresh/skills orchestration. + +- **Split `skill-settings` internal tests into a dedicated module file**: moved in-file tests from `src/lib.rs` into `src/tests.rs` to keep the primary module focused. + +### Build + +- **CI lint gate enabled**: made Biome lint blocking in CI by removing advisory `continue-on-error` behavior. +- **Pinned apt-cache action version**: replaced floating `awalsh128/cache-apt-pkgs-action@latest` with `@v1` in CI/release workflows. +- **Reduced CI duplication for ONNX setup**: extracted Linux ONNX Runtime installation/export logic into `scripts/install-onnxruntime-linux.sh` and wrapped it in reusable action `.github/actions/setup-onnxruntime-linux`, now used by both Linux CI jobs. +- **Reduced CI duplication for Vulkan setup**: added reusable action `.github/actions/setup-vulkan-linux` (cache + install) and adopted it in Linux CI and release workflows. +- **Reduced CI duplication for Rust bootstrap**: added reusable action `.github/actions/setup-rust-bootstrap-linux` and adopted it in Linux CI/release jobs for Rust toolchain + sccache setup. +- **Engineering health metrics in CI/release**: added per-job metrics artifacts and step summaries for Rust/frontend test durations, sccache stats, cargo compile timings upload, and Linux release binary/package sizes. +- **Automated dependency updates**: added `.github/dependabot.yml` to schedule weekly npm and Cargo dependency update PRs. +- **Repository ownership map**: added `.github/CODEOWNERS` assigning crates, frontend, CI workflows, and release scripts to `@eugenehp`. +- **Rust lint hardening (phase 1)**: promoted critical clippy lints in workspace config (`unwrap_used`, `panic`, `undocumented_unsafe_blocks`) from `warn` to `deny`. + +- **Added i18n guard steps to PR build workflow**: `pr-build.yml` now runs `sync:i18n:check`, `audit:i18n:check`, and `check:i18n:critical` (de/he) after dependency install so translation drift is caught before expensive packaging/signing steps. + +- **Standardized local quality gates**: expanded `.githooks/pre-commit` to run i18n checks, frontend checks (`svelte-check` + unit tests), and targeted Rust clippy/tests for touched crates. +- **Added heavy pre-push gate**: new `.githooks/pre-push` runs full frontend checks, full Rust clippy (workspace + app features), and workspace Rust library tests before push. + +- **Updated CI workflows to check all locales**: both `ci.yml` and `pr-build.yml` now run `check:i18n:locales` (all non-`en` locales) instead of a de/he-only check. + +- **Wired critical-locale i18n guard into CI**: frontend CI now runs `check:i18n:critical` after sync/audit checks to catch untranslated fallback markers early. + +- **Expanded integration-test crate detection**: CI now includes `skill-history` and `skill-settings` in integration-test crate selection. + +- **Broadened integration-test targeting in CI**: added `skill-router` and `skill-label-index` to the integration-test crate detection list. + +- **Expanded integration-test crate detection in CI**: added `skill-skills` to the integration-test crate selection list in `ci.yml`. + +### UI + +- **Extracted LLM advanced inference settings into a dedicated component**: moved the collapsible inference/settings panel from `src/lib/LlmTab.svelte` into `src/lib/llm/LlmInferenceSection.svelte`. + +- **Extracted LLM model picker into a dedicated component**: moved family selection, quant list rendering, hardware-fit badges, and vision-projector controls from `src/lib/LlmTab.svelte` into `src/lib/llm/LlmModelPickerSection.svelte`. + +- **Extracted LLM server log viewer into a dedicated component**: moved filtering/search/auto-scroll log UI from `src/lib/LlmTab.svelte` into `src/lib/llm/LlmServerLogSection.svelte`. + +- **Extracted screenshots capture settings panel into a dedicated component**: moved interval/image-size/quality controls and embedding backend/model selectors from `src/lib/ScreenshotsTab.svelte` into `src/lib/screenshots/ScreenshotCaptureSettingsSection.svelte`. + +- **Extracted screenshots OCR panel into a dedicated component**: moved OCR engine selection, model info, and search-hint UI from `src/lib/ScreenshotsTab.svelte` into `src/lib/screenshots/ScreenshotOcrSection.svelte`. + +- **Extracted screenshot pipeline performance UI into a dedicated component**: moved the large performance charts and breakdown panel from `src/lib/ScreenshotsTab.svelte` into `src/lib/screenshots/ScreenshotPerformanceSection.svelte`. + +- **Extracted screenshots permission notice into a dedicated component**: moved macOS screen-recording warning/success UI into `src/lib/screenshots/ScreenshotPermissionNotice.svelte`. +- **Extracted screenshots privacy note into a dedicated component**: moved the storage/privacy footer block into `src/lib/screenshots/ScreenshotPrivacyNote.svelte`. + +- **Extracted screenshots re-embed/status block into a dedicated component**: moved model-change warning, re-embed controls, progress bar, and stats from `src/lib/ScreenshotsTab.svelte` into `src/lib/screenshots/ScreenshotReembedSection.svelte`. + +- **Extracted Agent Skills UI into a dedicated component**: moved the large Agent Skills block out of `src/lib/ToolsTab.svelte` into `src/lib/tools/AgentSkillsSection.svelte`, including markdown skill description rendering and license panel behavior. + +- **Extracted Chat Tools settings panel into a dedicated component**: moved the large tools configuration UI from `src/lib/ToolsTab.svelte` into `src/lib/tools/ChatToolsSection.svelte`, including tool toggles, provider settings, web-cache controls, execution limits, compression, and retry settings. + +- **Split large settings tabs into sub-components**: extracted reusable sections from `LlmTab`, `ToolsTab`, and `ScreenshotsTab` into `LlmServerSection`, `SkillsRefreshSection`, `SuggestSkillCta`, and `ScreenshotToggleCard` to reduce template size and improve maintainability. + +### i18n + +- **All-locale i18n checks**: updated `scripts/sync-i18n.ts` and `scripts/audit-i18n.ts` to discover non-English locales dynamically from `src/lib/i18n`, so CI checks automatically cover every locale instead of a hardcoded subset. + +- **Replaced de/he-only fallback-marker guard with all-locale guard**: `scripts/check-critical-i18n-locales.js` now validates every discovered non-English locale directory (currently `de`, `fr`, `he`, `uk`) for `TODO: translate` fallback markers. + +- **Added critical-locale translation guard for German/Hebrew**: introduced `scripts/check-critical-i18n-locales.js` and `npm run check:i18n:critical` to fail when de/he locale files contain auto-sync TODO fallback markers. + +- **Stronger translation drift guard in CI**: added `npm run audit:i18n:check` to frontend CI so missing locale coverage is caught alongside sync checks. + +### Docs + +- **LLM bootstrap requirements documented**: added a new section to `docs/LLM.md` describing the default bootstrap model (`LFM2.5 1.2B Instruct`), approximate memory needs, and the backend autolaunch memory guard behavior. + +- **Updated `TODO.md`**: removed completed item for decomposing `LlmTab`, `ToolsTab`, and `ScreenshotsTab` templates into sub-components. + +## [0.0.70] — 2026-03-25 + +### Bugfixes + +- **Windows manifest root architecture**: set `src-tauri/manifest.xml` root `assemblyIdentity.processorArchitecture` to `amd64` (instead of wildcard) so `mt.exe -validate_manifest` passes and Windows CI proceeds to Rust compilation. + +## [0.0.69] — 2026-03-25 + +### Bugfixes + +- **Windows startup side-by-side fix**: simplified and normalized the embedded `src-tauri/manifest.xml` to a schema-safe structure (compatibility, common controls, DPI settings) to avoid side-by-side startup failures after manifest embedding. + +### Build + +- **Validate Windows manifest in CI**: added `scripts/check_windows_manifest.py` and wired it into `ci.yml` and `release-windows.yml` so malformed `src-tauri/manifest.xml` fails fast before Windows build/release. + +- **Native manifest lint on Windows CI**: added `mt.exe -validate_manifest` checks in Windows CI and release workflows to catch side-by-side manifest issues with the native Microsoft validator. + +## [0.0.68] — 2026-03-24 + +### Performance + +- **Selective crate testing in CI**: `cargo test` now only runs for crates affected by the current changeset. A new `scripts/changed-crates.sh` script computes the transitive closure of reverse dependencies from changed files, so PRs that touch a single crate skip unrelated test suites. Both unit tests (`--lib`) and integration tests (`--test '*'`) are selectively run for affected crates. Workspace-wide changes (Cargo.lock, .cargo/config.toml, ci.yml) still trigger a full test run as a safety net. + +### Bugfixes + +- **Stabilize CI ORT linking**: preinstall ONNX Runtime 1.23.2 from Microsoft releases on Linux/Windows CI jobs, then export `ORT_LIB_LOCATION` and `ORT_PREFER_DYNAMIC_LINK=1` so `ort-sys` does not depend on flaky `cdn.pyke.io` downloads during clippy/test runs. + +- **Cache ONNX Runtime binaries in CI**: `ort-sys` downloads ~200 MB static libraries from `cdn.pyke.io` during build. Added `actions/cache` for the download directory (`~/.cache/ort.pyke.io` on Linux, `%LOCALAPPDATA%/ort.pyke.io` on Windows) across CI, release-linux, and release-windows workflows to prevent recurring build failures caused by CDN flakiness. + +- **Ensure Windows release bundles ONNX Runtime DLL**: release workflow now installs ONNX Runtime 1.23.2 from Microsoft releases, exports `ORT_LIB_LOCATION` / `ORT_PREFER_DYNAMIC_LINK`, and stages `onnxruntime.dll` into release output directories before NSIS packaging. + +- **Windows: switch to dynamic CRT with app-local DLL bundling**: Removed `+crt-static` which failed for native dependencies (DirectML, ONNX Runtime, Vulkan loader) that ship as pre-built `/MD` DLLs. The build now uses `/MD` (dynamic CRT) consistently, and the NSIS installer bundles `vcruntime140.dll`, `msvcp140.dll`, and related CRT DLLs alongside `skill.exe` for app-local deployment. The VC++ Redistributable download section is also enabled by default as a system-wide fallback. This eliminates the "side-by-side configuration is incorrect" errors on Windows machines without the VC++ Redistributable. + +- **Fix Windows NSIS packaging strict-mode crash**: normalize the `$missingCrt` result to an array before reading `.Count` in `scripts/create-windows-nsis.ps1`. This prevents a PowerShell strict-mode failure when `Where-Object` returns `$null` (no missing CRT DLLs). + +### Refactor + +- **Normalized LLM catalog format**: Refactored `llm_catalog.json` from a flat array of 389 entries to a normalized `families` + `models` structure. Family metadata (name, description, repo, tags, params_b, max_context_length) is stored once per family instead of duplicated across every quant. File size reduced from 264 KB to 136 KB (48% smaller). The persisted user catalog in `skill_dir` auto-migrates from the old flat format on first load — no user action required. Runtime in-memory representation (`LlmCatalog` with flat `Vec`) is unchanged, so all downstream Rust and frontend code continues to work without modification. + +### Build + +- **Windows CI: replace static CRT verification with dependency logging**: The release workflow now logs binary dependencies and verifies the CRT redist DLLs can be located for bundling, instead of failing on dynamic CRT linkage. + +### LLM + +- **Qwen3 30B-A3B Instruct full quant coverage**: Added all 9 missing GGUF quants (IQ3_S, IQ4_XS, Q3_K_S variants) from `byteshape/Qwen3-30B-A3B-Instruct-2507-GGUF` to the LLM catalog, bringing the total to 15 entries covering the full range of available quantisations. + +## [0.0.67] — 2026-03-24 + +### Bugfixes + +- **CI: remove skill-screenshots from cargo test step**: `skill-screenshots` has no `#[test]` items but pulling it into the test step forced a link of `ort-sys`/`libonnxruntime`, which fails on Linux because `cargo clippy` type-checks without final linking while `cargo test` must produce a real binary. Removing it from the `-p` list eliminates the spurious ORT link failure with zero loss of test coverage; `cargo clippy --workspace` continues to verify the crate compiles correctly. + +- **CI: fix `ort-sys` link error on Linux/Windows**: `ort` was declared with `default-features = false` and no download strategy in the base `[dependencies]`, so `ort-sys` could not find `libonnxruntime` on Linux/Windows CI runners. Moved `ort` into target-specific sections — `download-binaries` for Linux/Windows (fetches the pre-built shared library at build time), `coreml` for macOS (unchanged). + +- **Release: bundle ONNX Runtime library on Linux and Windows**: `ort-sys` downloads `libonnxruntime.so` / `onnxruntime.dll` into Cargo's `OUT_DIR` at build time. The binary links against it dynamically, but the packaging scripts did not include it — deployed binaries would fail to start on users' machines. Fixed by finding the library in the build tree and bundling it with every release format: portable tarball, `.deb`, `.rpm` (Linux) and NSIS installer (Windows). On Linux, `patchelf --add-rpath '$ORIGIN'` is also applied so the dynamic linker finds the bundled `.so` relative to the binary regardless of system paths. + +- **skill-skills submodule test**: Added `submodules: true` to the `rust-check` CI checkout step so the `skills/` git submodule is fully populated and `discover_real_skills_submodule` runs (and passes) in CI instead of seeing an empty directory. + +## [0.0.63] — 2026-03-24 + +### Features + +- **Editable bash commands in tool calling**: added a pluggable `BashEditHook` callback and `require_bash_edit` setting in `LlmToolConfig`. When enabled, every LLM-generated bash command is presented to the user for review/editing before execution. The user can modify the command or cancel it entirely. Register a UI callback via `set_bash_edit_hook()` at app startup. Backward compatible — when no hook is registered or the setting is off, commands execute as before. +- **Record average SNR in session sidecar JSON**: the session runner now accumulates SNR (dB) across all band-power snapshots during a recording and writes `avg_snr_db` to the session sidecar JSON file on session end. The `SessionEntry` struct in `skill-history` now includes `avg_snr_db: Option` for frontend filtering. Legacy sessions without the field gracefully default to `None`. + +- **Bash edit hook registered at app startup**: the Tauri app now registers a native dialog-based bash edit hook during setup, so the `require_bash_edit` setting is functional end-to-end. + +- **Backfill average SNR for legacy sessions**: when loading session history, sessions without `avg_snr_db` in their sidecar JSON now get it computed on the fly from the per-epoch metrics in the SQLite database. This is a lightweight `AVG()` query per session — no full data reload needed. Legacy recordings seamlessly show SNR values without re-recording. + +- **Network retry with exponential backoff**: Web tools (`web_search`, `web_fetch`, `location`) now automatically retry on transient errors (HTTP 429/5xx, connection failures) with configurable exponential backoff. The retry policy is exposed in Settings > Tools with controls for max retries (0-3) and base delay (500-3000 ms). Default: 2 retries with 1 s base delay. + +- **Per-round usage tracking**: Tool orchestration now emits `ToolEvent::RoundComplete` events with cumulative `prompt_tokens`, `completion_tokens`, and `tool_calls_count` after each inference + tool-execution round, enabling cost/usage observability in agentic loops. + +- **Increased default max tool rounds**: Bumped the default maximum tool-calling rounds from 10 to 15, giving the agent more room for complex multi-step tasks. The Settings UI now includes a 15-round option. + +- **Updater: no auto-restart, session-safe**: Updates are now downloaded automatically in the background, but the app no longer auto-restarts after downloading. The user must explicitly click "Restart Now" to apply. Restarting is **blocked** while an EEG session is being recorded — a warning is shown and the restart button is disabled until the session ends. Only the user can bypass this protection. When quitting the app (via menu, tray, or Cmd+Q), if a downloaded update is staged, the app relaunches to apply it instead of just exiting. + +- **Persistent web cache for tool results**: Added a disk-backed cache (`skill_dir/web_cache/`) for `web_search` and `web_fetch` results. Avoids redundant network calls when the LLM re-fetches the same URL or repeats the same query within/across conversations. Entries are keyed by SHA-256 hash and expire via configurable TTLs. Expired entries are evicted on startup. + +- **Configurable TTL and per-domain overrides**: `WebCacheConfig` in `LlmToolConfig` with `search_ttl_secs` (default 30 min), `fetch_ttl_secs` (default 2 hours), and `domain_ttl_overrides` map for fine-grained control (e.g. shorter TTL for news sites). + +- **Frontend structured logger**: added `$lib/logger.ts` with `log.debug/info/warn/error` — `console.log`/`console.debug` are now stripped from production builds via esbuild `pure` config. + +- **Interactive search logic extraction**: extracted `search-interactive-logic.ts` with pure functions for display graph building, screenshot enrichment, node serialisation, and closest-screenshot selection — all with unit tests (9 new tests). + +- **Property-based tests**: added `proptest` to `skill-tools` (9 tests: JSON scanner invariants, tool-call extraction robustness, Llama XML parsing) and `skill-eeg` (4 tests: FFT/IFFT round-trip, PSD non-negativity, batch length). + +- **Headless browser unit tests**: added `InterceptStore` tests (5 tests: empty state, push/snapshot, clear, snapshot-with-clear, thread safety). + +- **skill-llm catalog type tests**: added 6 unit tests for `LlmModelEntry` — is_split, shard_count, all_filenames (single + sharded), DownloadState default/serde roundtrip. + +### Performance + +- **Fix sccache on Windows CI (57 min -> ~20 min builds)**: The `sccache-action` v0.0.9 on Windows fails to add the sccache directory to the Windows `Path` variable. Git Bash can find sccache (Unix PATH), but native processes like `cargo.exe` cannot, so `RUSTC_WRAPPER=sccache` silently fails and every build compiles 840+ crates from scratch with zero caching. Fixed by resolving sccache to its full Windows path and exporting `RUSTC_WRAPPER` as an absolute path via `GITHUB_ENV`. Also adds the sccache directory to `GITHUB_PATH` so cmake compiler launchers can find it. + +- **criterion benchmark suite**: added `crates/skill-eeg/benches/dsp_bench.rs` with benchmarks for FFT (128–1024), IFFT, PSD, BandAnalyzer, and EegFilter. Run via `cargo bench -p skill-eeg`. + +### Bugfixes + +- **Fix 5 broken doctests across the workspace**: marked uncompilable doc examples as `ignore` in `skill-tools/src/log.rs`, `skill-llm/src/log.rs`, `skill-tts/src/log.rs`, `skill-headless/src/lib.rs`, `src-tauri/src/lib.rs`, and `src-tauri/src/skill_log.rs`. All workspace tests now pass with zero failures. + +- **Fix dead_code CI error in skill-tools**: Changed `#[allow(dead_code)]` to `#[expect(dead_code)]` on `clear_bash_edit_hook()` so it correctly suppresses the lint under `-D warnings`. + +- **LLM decode retry on transient Metal failure**: When the initial prompt decode fails (commonly seen on macOS as "decode error on prompt (batch at token 0)"), the engine now clears the KV cache, waits briefly, and retries the full prompt once before reporting an error. This handles transient Metal GPU failures (busy command buffer, timeout) that previously required restarting the LLM. The same retry logic applies to multimodal (mtmd) eval. + +- **LLM server start never transitions from "loading"**: Fixed a bug where clicking "Start" in the chat window would show "loading" indefinitely. The root causes were: (1) the status poll timer self-cancelled on mount when status was "stopped" and was never restarted when the user clicked Start, (2) failed init paths (`init()` returning `None`, actor early-exit on model load or context creation failure) did not emit an `llm:status` event so the UI was never notified. Now the poll timer is restarted on every start attempt, and all failure paths emit a `"stopped"` status event with an error message. + +- **Fix svelte-check errors**: Added missing `avg_snr_db` default in history-helpers test factory; added `aria-label` to dismiss-error button in chat page. + +- **Fix Windows CI PowerShell parse error**: Replaced literal em dash characters in PowerShell `run:` blocks in `release-windows.yml`. Non-ASCII in CI scripts can cause encoding corruption and parser failures on Windows runners. + +- **Add missing tests for under-tested crates**: added 28 new unit tests across three crates: + - `skill-llm`: 6 tests for `estimate_memory_gb` and `recommend_ctx_size` (catalog/memory), 7 tests for `ThinkTracker` budget enforcement (engine/think_tracker). + - `skill-commands`: 11 tests for `query_slug`, `file_ts`, `pca_2d`, and `pca_3d` pure utility functions. + - `skill-screenshots`: 4 tests for `ScreenshotMetrics` atomics and `MetricsSnapshot` serialization. + +### Refactor + +- **`retry_with_backoff` helper**: Added a generic `retry_with_backoff(max_retries, base_delay, closure)` utility in `skill-tools::exec::helpers`, reusable across any blocking I/O path. Exported from the crate root. + +- **`ToolRetryConfig` struct**: New `ToolRetryConfig { max_retries, base_delay_ms }` in `LlmToolConfig`, serialisable and configurable per-user. Wired through to `exec_location`, `exec_web_fetch_plain`, and `exec_web_search`. + +- **Biome frontend linter/formatter**: added `biome.json` config, `npm run lint/format` scripts, and CI integration for consistent TS/Svelte code style. Formatted all 257 source files. + +- **Structured error types**: added `thiserror` to workspace deps and created `error.rs` modules in `skill-data`, `skill-llm`, and `skill-tools` with typed error enums (`SessionError`, `DownloadError`, `ValidationError`, etc.) for pattern-matchable error handling. + +- **Faster dev builds**: scoped the `incremental = false` workaround to just `candle-core` and increased `codegen-units` to 8 (was 1) for the dev profile. + +- **Dead code cleanup**: removed blanket `#![allow(dead_code)]` from `skill-tools/parse`, replaced `HooksLog.path` with `_path` prefix, fixed broken syntax in `scripts/bump.js`. + +- **Dashboard logic extraction**: extracted `dashboard-logic.ts` with pure functions for EEG score computation (`sigmoid100`, `computeRawScores`, `emaSmooth`), display formatting (`fmtUptime`, `fmtEeg`, `redact`), goal progress, and device classification — all with 22 unit tests. + +- **Onboarding logic extraction**: extracted `onboarding-logic.ts` with model selection functions (`pickFamilyTarget`, `pickLlmTarget`) — 13 unit tests covering quantization preference, family matching, download-skip logic. + +- **Doc comments**: added `///` documentation to 16 key public types across `skill-history` and `skill-commands` (SessionEntry, SessionMetrics, EpochRow, SearchResult, DayIndex, etc.). + +- **SAFETY comment for unsafe block**: added missing `// SAFETY:` comment to `ManuallyDrop::new(unsafe { std::mem::zeroed() })` in `skill-llm/src/engine/actor.rs`. + +- **Remove 12 unused `anyhow` deps**: removed `anyhow` from skill-commands, skill-devices, skill-eeg, skill-gpu, skill-headless, skill-health, skill-jobs, skill-label-index, skill-screenshots, skill-settings, skill-tray, skill-vision — none were importing it. + +- **Wire thiserror into skill-tools API**: `validate_tool_arguments` now returns `Result` instead of `anyhow::Result`, making validation errors pattern-matchable by callers. Re-exported error types from all 3 crate roots. + +- **Split skill-history**: extracted 748 lines of metrics/CSV loading into `skill-history/src/metrics.rs`. `lib.rs` reduced from 1766 to 1019 lines. + +- **Zero clippy warnings**: fixed unsafe block SAFETY comment placement in skill-gpu, converted 2 `match` patterns to `let...else` in skill-tools web_cache, replaced redundant closure with method reference. + +- **UmapViewer3D logic extraction**: extracted `umap-viewer-logic.ts` with pure functions for point-cloud normalization, random positions, Turbo colormap, RGB-to-hex conversion, and color array building — 14 unit tests. + +- **DevicesTab logic extraction**: extracted `devices-logic.ts` with fuzzy matching, device image resolution (Muse, Emotiv, IDUN, OpenBCI, Hermes), OpenBCI channel labeling, and relative-time formatting — 21 unit tests. + +- **LlmTab logic extraction**: extracted `llm-tab-logic.ts` with hardware-fit badge styling, icons, and labels — 11 unit tests. + +- **GoalsTab logic extraction**: extracted `goals-logic.ts` with progress bar coloring and minute formatting — 8 unit tests. + +- **HooksTab logic extraction**: extracted `hooks-logic.ts` with timestamp conversion (microsecond/millisecond/second auto-detection) and relative-age formatting — 10 unit tests. + +- **ScreenshotsTab logic extraction**: extracted `screenshots-logic.ts` with rolling history buffer, microsecond/millisecond/ETA formatting, and SVG sparkline/area path generation — 16 unit tests. + +- **InteractiveGraph3D logic extraction**: extracted `graph3d-logic.ts` with 3D vector math (`add3`, `scale3`, `normalize3`, `length3`), Fibonacci sphere layout, and Turbo colormap (`turbo`, `turboCss`, `turboHex`) — 16 unit tests. + +- **Biome lint fixes**: removed unused variables and `as any` casts in test files, replaced `!` non-null assertions with type-narrowing guards. + +- **Split `skill-llm/src/catalog.rs` into focused sub-modules**: decomposed the 1059-line monolithic file into 5 modules under `catalog/` — `mod.rs` (re-exports), `types.rs` (data types), `persistence.rs` (load/save/merge/queries), `memory.rs` (memory estimation, context-size recommendation), `download.rs` (resumable HuggingFace downloader). All public API re-exports preserved. + +- **Split `skill-tools/src/exec.rs` into focused sub-modules**: decomposed the 1724-line monolithic file into 9 focused modules under `exec/` — `mod.rs` (dispatch, 97 lines), `tools_system.rs` (date/location/bash/skill), `tools_web.rs` (web_search/web_fetch), `tools_fs.rs` (read_file/write_file/edit_file/search_output), `status.rs` (status text formatter), `truncate.rs` (output truncation helpers), `safety.rs` (dangerous-operation detection and approval), `helpers.rs` (path resolution, UTC formatting), `tests.rs`. No file exceeds 345 lines. +- **Split `skill-commands/src/graph.rs` into focused sub-modules**: decomposed the 1482-line monolithic file into 5 modules under `graph/` — `mod.rs` (re-exports), `dot.rs` (Graphviz DOT generation), `svg.rs` (2-D layered SVG), `svg_3d.rs` (3-D perspective SVG), `tests.rs`. All public API re-exports preserved for backward compatibility. + +### Build + +- **Node 22 in CI**: Bumped all workflows from Node 20 to Node 22, satisfying `camera-controls@3.1.2` engine requirement and aligning with current LTS. +- **esbuild ETXTBSY retry**: Added `npm ci || npm ci` retry in ci.yml to handle transient ETXTBSY race condition during esbuild postinstall on Linux runners. + +- **cargo-deny**: added `deny.toml` for license compliance, duplicate detection, and advisory checking. Integrated into CI audit job. + +- **Enhanced pre-commit hook**: now checks Rust formatting (`cargo fmt`), frontend formatting (Biome), and i18n sync — not just i18n. + +- **CI improvements**: added Biome format check, cargo-deny, and expanded Rust test coverage to include `skill-headless`, `skill-screenshots`, `skill-label-index`, `skill-skills`, `skill-jobs`, `skill-commands`, `skill-exg`. + +- **Workspace dependency consolidation**: promoted `thiserror` and `base64` to `[workspace.dependencies]` to ensure single-version consistency. + +- **rustfmt.toml**: added workspace-level rustfmt configuration (`edition = "2021"`, `max_width = 120`). + +- **Leaner dependency graph**: 12 crates no longer transitively pull in `anyhow` when they don't use it. + +### CLI + +- **Tauri commands**: `web_cache_stats`, `web_cache_list`, `web_cache_clear`, `web_cache_remove_domain`, `web_cache_remove_entry` for frontend access to the cache. + +### UI + +- **Bash command review toggle in chat tools panel**: added a "Review bash commands" toggle (visible when bash tool is enabled) that activates the `require_bash_edit` setting. Every LLM-generated bash command is shown in a native dialog for user approval before execution. Includes i18n translations for all 5 languages. +- **SNR display in session history**: the expanded session detail view now shows the average signal-to-noise ratio (dB) with color coding — green (>= 10 dB good), amber (>= 0 dB fair), red (< 0 dB poor). Includes i18n translations for all 5 languages. + +- **LLM loading progress steps in chat window**: The chat window now shows a step-by-step progress indicator while the LLM server is starting, replacing the generic bouncing dots. Each phase is shown with a spinner (active), checkmark (done), or dot (pending): Loading model weights → Creating context → Loading vision projector → Compiling GPU shaders. A progress bar tracks overall advancement. The current loading phase is also shown in the chat header (visible even when messages exist from a previous conversation). All steps are fully localised (en, de, fr, he, uk). + +- **Retry settings in Tools tab**: New "Network retry" section in Settings > Tools with button-group selectors for max retries and base delay, matching the existing visual style. + +- **Web cache management panel**: New section in Settings > Tools (under web search) with: + - Enable/disable toggle + - Search TTL and fetch TTL button-group selectors (5/15/30/60 min and 30/60/120/240 min) + - Live stats (entry count, total size) + - Scrollable entry list with kind badge, domain, label, age, and size + - Per-entry remove button, per-domain remove button, and clear-all button + +- **Show LLM start errors in chat window**: When the LLM server fails to start, a dismissible red error banner now appears below the chat header with the failure reason (e.g. "no model selected", "model file not found", "failed to create context"). Previously the error was only logged to the browser console. + +### i18n + +- **Retry setting strings**: Added `llm.tools.retry*` keys to all five locales (en, de, fr, he, uk). + +- **Web cache strings**: Added `llm.tools.webCache*` keys to all five locales (en, de, fr, he, uk). + +### Docs + +- **Fix all 18 `cargo doc` warnings across the workspace**: resolved unresolved intra-doc links, fixed references to private items, escaped HTML tags in doc comments, and corrected macro link syntax in `skill-llm`, `skill-tools`, `skill-tts`, `skill-eeg`, `skill-data`, `skill-headless`, and `src-tauri`. The workspace now builds documentation with zero warnings. + +- **CONTRIBUTING.md**: added comprehensive contributor guide covering prerequisites, project structure, dev workflow, code quality commands, commit checklist, and architecture notes. + +- **TODO.md**: replaced completed items with remaining improvement opportunities (skill-history split, criterion benchmarks, Svelte component decomposition, i18n gaps). + +- **Fix cargo doc warnings**: resolved unresolved link to `set_bash_edit_hook` in skill-tools and `gpu_fft::psd::psd` in skill-eeg. + +- **Fix cargo doc warnings**: resolved unresolved doc links in skill-tools and skill-eeg. + +### Dependencies + +- **sha2**: Added `sha2 = "0.10"` to `skill-tools` for cache key hashing. + +## [0.0.62] — 2026-03-24 + +### Performance + +- **Batch clippy and test in bump**: `npm run bump` now runs `cargo clippy` and `cargo test` once each for all workspace crates instead of per-crate (38 → 7 steps), and no longer deletes `src-tauri/target/` after bumping, preserving the build cache for subsequent runs. Pass `--clean` to free disk space when needed (`npm run bump -- --clean`). + +### Bugfixes + +- **Fix useCargo ReferenceError in tauri-build.js**: The `useCargo` variable was referenced in the early-exit pass-through block (line 161) before its definition (line 637), causing a `ReferenceError` for non-build/dev subcommands. Inlined the check at the early-exit point. + +### CLI + +- **`npm run bump --dry-run`**: added `--dry-run` flag that runs all preflight checks (clippy, tests, i18n, etc.) without modifying any version files, changelog, or build artifacts. + +- **Bump progress bar**: `npm run bump` now shows a live progress bar during preflight checks with step counter (N/total), elapsed time, and ETA. Clippy and test steps run per-crate for granular visibility instead of one monolithic command. + +- **TUI progress bar for `npm run bump`**: Preflight checks now display a full TUI with scrolling log output above a pinned progress bar. The progress bar updates every second with elapsed time and ETA, and command output streams in real-time instead of being hidden. + +## [0.0.61] — 2026-03-24 + +### CLI + +- **Detect competing cargo processes in bump**: `npm run bump` now checks for other running `cargo` processes before starting clippy/test preflight checks and warns that they may cause hangs due to the global cargo package-cache lock. The user is prompted to continue or abort. + +## [0.0.60] — 2026-03-24 + +### Performance + +- **LLM E2E reuses clippy build cache**: The `llm-e2e` CI job now runs after `rust-check` (`needs: rust-check`) and shares its Cargo build cache via `Swatinem/rust-cache` (`save-if: false`). This turns the E2E compilation into a cheap incremental build instead of a full rebuild (~3–5 min saved). The linker (mold + clang), target triple, and `CMAKE_*_COMPILER_LAUNCHER: sccache` env vars are aligned with `rust-check` to maximise cache hits. The Vulkan SDK (~200 MB) now uses the same `actions/cache` key as `rust-check`, skipping download on cache hit. System deps (including mold + clang) are merged into one cached `awalsh128/cache-apt-pkgs-action` step, eliminating a separate `apt-get update` round-trip. +- **Halve LLM E2E test runtime (67s → 33s)**: Reduced `ctx_size` from 4096 to 2048 (test prompts are < 600 tokens). Reduced tool-chat `max_tokens` from 128 to 64 — the model was wasting inference echoing full JSON results (117-128 tokens) when it only needs ~25 tokens for the tool call and a short summary. Added `[profile.test.package.llama-cpp-sys-4]` and `llama-cpp-4` with `opt-level = 3` so the C++ inference engine runs optimized even in test builds, boosting tok/s by ~20%. +- **LLM E2E is now opt-in**: The `llm-e2e` job no longer runs automatically on push. It is triggered only via manual `workflow_dispatch` with the `run_llm_e2e` checkbox enabled. The Discord notification shows ⏭️ when skipped. + +### Bugfixes + +- **Regenerate Cargo.lock during bump**: `npm run bump` now runs `cargo generate-lockfile` after updating version in `src-tauri/Cargo.toml`, preventing CI `--locked` build failures due to stale lockfile. + +- **Add missing safety comment for unsafe block**: Added `// SAFETY:` comment on the Linux `RLIMIT_STACK` unsafe block in `main.rs` to satisfy `clippy::undocumented_unsafe_blocks` (Rust 1.94). + +- **Fix i18n test import shadowing**: The `extractKeysFromDir` import from `i18n-utils.ts` was shadowing the local test helper of the same name, causing 8 pre-existing key-sync test failures. Renamed to `extractKeysWithValues` in the test import. + +### Build + +- **CI/Release speed improvements**: Estimated 7-12 min savings per CI run via multiple optimizations: + - Removed redundant `cargo check` step — `clippy` is a strict superset and already compiles everything. + - Merged duplicate `cargo-audit` and `audit` jobs into a single security audit job. + - Moved audit to run only on main/develop pushes (advisory, not PR-blocking). + - Added concurrency groups to `ci.yml` to cancel superseded runs on the same branch/PR. + - Replaced manual `--workspace -p crate1 -p crate2 ...` clippy invocations with `--workspace --exclude skill`. + - Switched Linux CI from manual `actions/cache` to `Swatinem/rust-cache` for smarter per-crate invalidation (matching release workflows). + - Cached Vulkan SDK on Linux CI (previously downloaded ~200 MB on every run). + - Added `mold` fast linker + `clang` to Linux CI (previously only in release-linux). + - Added `fetch-depth: 1` to CI jobs that don't need full git history. + - Added `--locked` flag to all release cargo build commands for reproducible builds. + - Added `--timings` flag and cargo-timings artifact upload to macOS and Linux release workflows (previously Windows only) for build profiling. + - Removed redundant `cargo check` on Windows CI — `clippy` already covers it. + - Fixed Discord notification to show "skipped" emoji for audit when it doesn't run on PRs. + - Added `CMAKE_C_COMPILER_LAUNCHER` / `CMAKE_CXX_COMPILER_LAUNCHER` sccache integration to macOS, Linux CI, and preview builds (previously Windows release only) so cmake-based -sys crate compilations (llama-cpp-sys, espeak-ng) are cached across runs. + +- **Commit Cargo.lock**: Removed `Cargo.lock` from `.gitignore` and committed it so that `cargo clippy --locked` and CI builds succeed without needing to regenerate the lockfile. + +### i18n + +- **Untranslated value detection**: Added a vitest check (`i18n untranslated value detection`) that fails when any non-English locale contains values identical to English that are not in the exemption list. The exemption logic (brand names, technical acronyms, formulas, academic citations, etc.) is now shared between `i18n-utils.ts`, the `audit-i18n.ts` script, and the test suite. A new `check:i18n` npm script runs the audit with `--check` for CI gating. + +- **Translate all untranslated strings**: Translated 388 strings across 4 locales (de, fr, he, uk) that were still in English. Covers common UI (errors, dismiss, zoom reset, goal reached), supported devices and setup instructions, device API settings, API authentication, screenshot/OCR pipeline labels, screen recording permissions, history streaks, LLM tool settings, onboarding, and search screenshot tab. Added brand/product names and cross-language cognates to the exemption list. + +## [0.0.59] — 2026-03-24 + +### Features + +- **Windows NSIS installer: optional runtime components**: The installer now has a Components page with smart auto-detection. Optional sections for Vulkan Runtime (GPU acceleration), VC++ 2015-2022 Redistributable, WebView2 Runtime (required for UI on older Windows 10), and GPU TDR timeout increase (prevents driver resets during long LLM inference). Each component auto-selects only when its prerequisite is missing. +- **Windows NSIS installer: kill running instance**: The installer detects if the app is running before upgrading and offers to close it (WM_CLOSE then taskkill). The uninstaller also force-kills the app before removing files. +- **Windows NSIS installer: long path support**: Enables the `LongPathsEnabled` registry key so HuggingFace model cache paths exceeding 260 characters work correctly. +- **Windows NSIS installer: firewall rule**: Adds a Windows Firewall exception for the local LLM/WebSocket server, preventing the "allow access?" popup on first launch. Cleaned up on uninstall. +- **Windows NSIS installer: launch at login**: A "Launch at login" component is selected by default. Writes the same `HKCU\...\Run\skill` registry key used by the in-app autostart setting. Users can uncheck it during install or toggle it later in Settings. +- **Windows NSIS installer: clean uninstall**: The uninstaller now removes the autostart registry entry (`HKCU\...\Run\skill`), the firewall rule, and optionally offers to delete the user data folder (`%LOCALAPPDATA%\NeuroSkill`) including settings, sessions, and downloaded models. + +### Refactor + +- **Adopt `anyhow` across all workspace crates**: Replaced `Result` error handling with `anyhow::Result` throughout the crate layer. `map_err(|e| format!(...))` chains are now `.context()` / `.with_context()`, and `Err(format!(...))` becomes `anyhow::bail!(...)`. The Tauri command boundary in `src-tauri/` converts back to `String` via `.map_err(|e| e.to_string())`. `skill-jobs` retains `Result` for stored/cloned job results. `skill-headless` retains its `HeadlessError` enum. A migration script (`scripts/adopt_anyhow.py`) automates the bulk of the conversion. + +## [0.0.58] — 2026-03-24 + +### Features + +- **Auto-launch after install on Windows**: The NSIS finish page now shows a "Launch NeuroSkill™" checkbox (checked by default). The app is launched as the current user (not elevated) via the Explorer shell trick, so per-user paths, tray registration, and autostart work correctly. + +- **Extracted `search-logic.ts` module**: Pulled pure business logic (mode normalization, UMAP label enrichment, time helpers, analysis chips) out of the 2,169-line search page into a testable TypeScript module with 9 unit tests. + +- **Extracted `compare-logic.ts` module**: Pulled timeline helpers, session range selection, pointer-to-UTC conversion, and date formatting out of the 1,922-line compare page into a testable module with 14 unit tests. + +- **Added DSP pipeline integration tests**: New `dsp_pipeline_test.rs` for `skill-eeg` covering band analysis at multiple sample rates, beta/alpha dominance detection, quality monitoring, and reset behaviour (5 tests). + +- **Added `skill-headless` tests**: New `intercept_tests.rs` covering `InterceptStore` push/snapshot/clear, serialization round-trip, and default state (5 tests). + +- **Added `skill-screenshots` tests**: New `context_tests.rs` covering mock context, fastembed model resolution, and `ActiveWindowInfo` defaults (5 tests). + +- **Added `skill-jobs` integration tests**: New `queue_tests.rs` covering submit-and-poll, sequential execution ordering, error propagation, queue positioning, and unknown-job handling (5 tests). + +- **Added `skill-data` tests**: New `hooks_log_tests.rs` (4 tests) and `screenshot_store_tests.rs` (4 tests) covering database creation, insert, count, and pagination. + +### Bugfixes + +- **Fixed 2 broken tests in `src-tauri/src/constants.rs`**: The `embedding_overlap_samples_correct` and `embedding_hop_samples_correct` tests were asserting stale values (640) after `EMBEDDING_OVERLAP_SECS` changed from 2.5 to 0.0. Tests now derive expected values from the actual constants. + +- **Fixed compilation error in `skill-headless`**: Two `eval_fire()` calls passed `reply` by value instead of by reference. + +- **Fix ™ symbol mangled in Windows NSIS installer**: The `.nsi` script was written as UTF-8 without BOM. NSIS with `Unicode True` requires a BOM to detect UTF-8; without it, NSIS falls back to the system ANSI codepage and corrupts non-ASCII characters like ™ in the product display name, version info, registry entries, and shortcuts. Changed to UTF-8 with BOM. + +- **Fix Windows SxS "side-by-side configuration incorrect" error**: Added `CMAKE_MSVC_RUNTIME_LIBRARY = "MultiThreaded"` to the workspace `.cargo/config.toml` `[env]` section so that cmake-based C/C++ dependencies (llama-cpp-sys, espeak-ng) use static CRT (`/MT`) matching Rust's `+crt-static` target feature. Previously this env var was only set in CI, causing local Windows builds to produce a CRT mismatch that triggered the SxS error on machines without the Visual C++ Redistributable. + +### Refactor + +- **Eliminated all clippy warnings across the workspace**: Resolved every warning from `cargo clippy --workspace` — from 500+ warnings to zero. + + - Converted 21 `match` → `let...else` patterns across 10 files (api.rs, screenshot_cmds.rs, ws_commands, device_scanner, session_runner, session_connect, eeg_embeddings, label_cmds, skill-skills/sync, skill-llm/actor). + - Replaced 17 `lock().expect("lock poisoned")` calls with `lock_or_recover()` across skill-llm (handlers, logging, state, tool_orchestration) and src-tauri/api. + - Applied `cargo clippy --fix` auto-fixes: redundant closures, method call simplifications, let-else conversions. + - Added `// SAFETY:` comments to remaining undocumented `unsafe` blocks (skill_log, quit, active_window, window_cmds). + - Added `#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]` to `build.rs` (build-time panics are standard practice). + - Added `#![allow(clippy::unwrap_used)]` to `image_encode_bench.rs` (benchmark binary). + - Disabled `needless_pass_by_value` lint (280 false positives from Tauri `#[command]` handlers). + - Disabled `expect_used` lint (50 legitimate uses in thread spawning, NonZero constructors, Tauri app builder — all unrecoverable). + +- **Split `skill-tools/src/parse.rs` into modules**: The 2,441-line monolith is now split into 7 focused sub-modules (`types`, `coerce`, `validate`, `extract`, `strip`, `inject`, `json_scan`) while preserving the full public API. All 161 tests continue to pass. + +- **Workspace-wide lint configuration**: Added `[workspace.lints]` in root `Cargo.toml` with consistent Clippy rules across all 22 crates — `unwrap_used`, `expect_used`, `undocumented_unsafe_blocks`, `needless_pass_by_value`, and more. All crates inherit via `[lints] workspace = true`. + +- **Eliminated `unwrap()` in library code**: Replaced the sole remaining `unwrap()` in production code (`skill-commands/src/graph.rs`) with safe `let-else`. All other `unwrap()` calls were already in tests/examples/benchmarks. + +- **Hardened `InterceptStore` lock handling**: Replaced `lock().expect("lock poisoned")` with graceful `if let Ok(guard)` pattern in `skill-headless` — poisoned locks now degrade gracefully instead of panicking. + +### Build + +- **Bust Windows CI caches**: Bumped Cargo and sccache cache keys (`windows-release-x86_64-msvc-v2`, `SCCACHE_GHA_VERSION=2`) to invalidate stale cmake artifacts that were compiled with dynamic CRT (`/MD`). +- **Verify static CRT in Windows CI**: Added a post-compile `dumpbin /dependents` check that fails the build if `vcruntime140.dll`, `ucrtbase.dll`, or `msvcp*.dll` appear as dependencies, catching dynamic CRT leaks before packaging. + +- **Added `cargo audit` to CI**: New `cargo-audit` job in the CI pipeline scans for known dependency vulnerabilities on every push and PR. + +### Docs + +- **Added SAFETY comments to all `unsafe` blocks**: Documented invariants for every `unsafe` block in `skill-vision` (FFI OCR), `skill-gpu` (IOKit/sysctl), `skill-llm` (llama.cpp backend), and `skill-screenshots` (CoreFoundation/AppKit FFI). + +## [0.0.57] — 2026-03-24 + +### Bugfixes + +- **Fix skill-headless build with wry 0.54**: Enable `rwh_06` feature on `tao` so `Window` implements `HasWindowHandle` required by wry 0.54. + +- **Fix mDNS discovery in smoke-test.sh**: Keep a single `dns-sd -B` process running continuously and poll its output every second, instead of spawning and killing a new process every 3 seconds (which could miss service announcements). + +- **Fix smoke-test false-positive mDNS match**: The `dns-sd -B` header line already contains "skill", so the grep matched immediately before any service was actually discovered. Changed pattern to `Add.*_skill._tcp` which only matches a real service registration event. + +- **Smoke test mDNS retry**: Moved mDNS discovery from `smoke-test.sh` (bash `dns-sd`) into `test.ts` (bonjour-service). Discovery now retries indefinitely with a 3-second backoff until the Skill server appears or the user presses Ctrl-C, fixing the "could not resolve port" failure on slow startups. + +- **Smoke test port discovery**: Fixed `smoke-test.sh` failing because `test.ts` tried its own 5-second mDNS browse which raced and failed. The script now resolves the port via `dns-sd -L` after the browse succeeds and passes it explicitly to `test.ts`, eliminating the double-discovery race condition. + +- **Fix smoke test on macOS**: Replace GNU `timeout` command (not available by default on macOS) with a portable Perl-based timeout in `smoke-test.sh`. + +- **Fix smoke-test unbound variable**: `scripts/smoke-test.sh` failed with `unbound variable` when invoked without arguments due to `set -u` and bare `${*}`. Changed to `${*:-}` to default to empty string. + +### CLI + +- **Move smoke-test to scripts/ and add npm script**: Moved `smoke-test.sh` into `scripts/` and added `npm run test:smoke` shortcut. + +## [0.0.56] — 2026-03-24 + +### Features + +- **Smoke test script**: Added `smoke-test.sh` and `npm run test:smoke` to launch the app and run `test.ts` end-to-end inside a tmux session, waiting for mDNS service discovery before starting tests. + +- **Full WS command test coverage**: Added smoke tests in `test.ts` for all previously untested WebSocket commands: `session_metrics`, calibration CRUD (`get_calibration`, `create_calibration`, `update_calibration`, `delete_calibration`), `sleep_schedule` / `sleep_schedule_set`, health commands (`health_summary`, `health_metric_types`, `health_query`, `health_sync`), and extended LLM management commands (`llm_downloads`, `llm_refresh_catalog`, `llm_hardware_fit`, `llm_select_model`, `llm_select_mmproj`, `llm_pause_download`, `llm_resume_download`, `llm_set_autoload_mmproj`, `llm_add_model`). All 58 dispatch table commands now have corresponding tests. + +### Performance + +- **Faster Windows release builds**: Added `CMAKE_C_COMPILER_LAUNCHER` and `CMAKE_CXX_COMPILER_LAUNCHER` env vars so sccache caches C/C++ compilations from cmake-based -sys crates (llama-cpp-sys-4, etc.), not just rustc. Added `[profile.release]` with `lto = "thin"` and `codegen-units = 8` for faster linking. Moved frontend build before SDK installs to reduce idle time. Combined NSIS and Vulkan SDK installs into a single parallel step. Added sccache GHA cache versioning and `--timings` cargo flag with artifact upload for build profiling. + +### Bugfixes + +- **Fix Windows CI zip assembly loading**: Load `System.IO.Compression` assembly explicitly before `System.IO.Compression.FileSystem` in the "Sign installer + create updater artifacts" step. On some PowerShell runtimes (notably PowerShell Core on GitHub Actions), loading only `FileSystem` does not implicitly load the base `System.IO.Compression` assembly, causing `Unable to find type [System.IO.Compression.ZipArchiveMode]` errors during NSIS updater zip creation. + +## [0.0.55] — 2026-03-24 + +### Bugfixes + +- **Fix Windows "side-by-side configuration" launch error**: Statically link the MSVC C/C++ runtime (`+crt-static`) into the Windows binary so the app no longer requires the Visual C++ Redistributable to be pre-installed. The espeak-ng static build and CI workflow also set `CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded` to ensure all C/C++ dependencies use the matching static CRT. + +- **Fix Windows auto-update "unsupported compression method"**: Replaced PowerShell `Compress-Archive` with .NET `ZipFile` using explicit Deflate compression (method 8) when creating updater `.nsis.zip` archives. `Compress-Archive` on newer Windows versions uses Deflate64 (method 9), which the Tauri updater's zip crate does not support. + +## [0.0.54] — 2026-03-23 + +### Bugfixes + +- **Fix clean:rust ENOTEMPTY on large target dirs**: Added retry options and `rm -rf` fallback to `scripts/clean-rust.js` so the Rust build artifact cleanup no longer fails with ENOTEMPTY on very large directory trees. + +- **Windows app manifest for BLE access**: Added a custom Windows application manifest (`manifest.xml`) declaring Windows 10/11 compatibility via `supportedOS` and `maxversiontested`. Without this, Windows 11 may deny WinRT Bluetooth Low Energy API access to unpackaged desktop apps. Also includes Common Controls v6 and per-monitor DPI awareness v2. + +- **Windows 11 Bluetooth permissions guidance**: Updated BLE error messages and the "Bluetooth is off" UI state to include Windows 11-specific instructions (Settings → Privacy & Security → Bluetooth). The "Open Settings" button now opens both the Bluetooth devices page and the Bluetooth privacy page on Windows. Added a Windows-specific adapter state check in `bluetooth_ok()` to detect powered-off adapters. Updated all locales (en, de, fr, he, uk). + +### Build + +- **Stop tracking Cargo.lock**: Removed all `Cargo.lock` files from version control and added `Cargo.lock` to `.gitignore`. + +- **Add libgbm-dev to Linux CI**: Added missing `libgbm-dev` package to CI and release workflows to fix linker error (`library not found: gbm`). + +## [0.0.53] — 2026-03-22 + +### Features + +- **Auto-pair first discovered device**: When the paired devices list is empty (fresh install or all devices unpaired), the first discovered device is automatically added to paired devices and auto-connected. This provides a seamless first-run experience without requiring manual pairing. Only triggers when no paired devices exist. + +- **Cortex WebSocket connection status indicator**: The Emotiv Cortex scanner backend now tracks and emits its WebSocket connection state (`disconnected`, `connecting`, `connected`) to the frontend in real time. + +- **Llama XML tool-call parsing**: `extract_tool_calls()` now recognises the `value` XML format emitted by Llama-family models. Both `` tag pairs and inline JSON bodies are supported. Stripping and streaming partial-tag handling are also included. + +- **Tool-call self-healing**: When the LLM emits a garbled or malformed tool call that cannot be parsed, the orchestrator now detects the failed attempt, injects a corrective message containing the raw output, and asks the model to re-emit in the correct format. Up to 2 retry attempts are made before falling back to normal output. This significantly improves reliability with smaller local models. + +### Bugfixes + +- **Emotiv credentials not persisting across restarts**: `DeviceApiConfig` fields used `#[serde(skip_serializing)]` to keep secrets out of the JSON settings file, but this also caused `get_device_api_config` to return empty credentials to the frontend via Tauri IPC. The command now returns a `serde_json::Value` that bypasses the skip, so stored keychain credentials are correctly displayed after restart and are no longer accidentally overwritten with empty values on subsequent saves. + +- **Keychain credentials not persisted on any platform**: The `keyring` crate v3.x requires explicit platform backend features. Without them, no credential store was compiled in and `set_password`/`get_password` silently failed, causing Emotiv (and IDUN / API token) credentials to be lost on restart. Enabled OS-specific backends via target dependencies (`apple-native` on macOS, `windows-native` on Windows, `linux-native-sync-persistent` + `crypto-rust` on Linux). Also improved error logging so keychain failures are no longer silently swallowed. + +- **KittenTTS: re-open audio device on every utterance**: The KittenTTS backend opened the system audio output once at startup and reused the same stream for all subsequent speech. If the device was unplugged, switched, or became unavailable (e.g. Bluetooth disconnect, USB DAC removal), playback failed with "The requested device is no longer available." The worker now re-opens the default audio device before each utterance, matching the NeuTTS backend behaviour. This is cheap (~1 ms) relative to synthesis time and ensures the current default device is always used. + +- **Screenshots not captured on Windows and Linux**: The `screenshots` feature (which enables the `xcap` screen-capture backend) was missing from the default Cargo features and from all CI/release build workflows. On macOS this was invisible because capture uses the `screencapture` CLI tool directly, but on Windows and Linux the capture function silently returned `None`. Added `screenshots` to the default feature set and to all build/CI workflows for Windows and Linux. + +- **Fix WebView2 window class unregistration error on Windows**: Stopped dropping the WebView early during Close command handling. The `Chrome_WidgetWin_0` class unregistration race (Win32 error 1412) occurred because the WebView was destroyed while the event loop and parent window were still alive. The WebView is now dropped naturally when `run_return` finishes, allowing Chromium's internal child windows to clean up before class unregistration. + +- **Fix Windows Vulkan STATUS_ACCESS_VIOLATION crash**: The crash was in the Vulkan validation layer (`VK_LAYER_KHRONOS_validation`) loaded by the Vulkan SDK during llama.cpp model loading on the `llm-actor` thread. In debug builds, the validation layer's `ErrorObject` constructor crashes inside `vkEnumeratePhysicalDevices` on certain Windows GPU drivers. Now disable the validation layer at app startup via `VK_LOADER_LAYERS_DISABLE` and `VK_INSTANCE_LAYERS` env vars (affects llama.cpp / ggml-vulkan) plus `WGPU_VALIDATION=0` (affects wgpu/cubecl). Added a Windows vectored exception handler that prints faulting address, thread name, and full backtrace for any future access violations. + +- **Fix window flickering on Windows**: The screenshot capture worker iterated through all non-minimized windows calling `PrintWindow` (via xcap `capture_image()`) on each one until it got a result. `PrintWindow` sends a `WM_PRINT` message forcing each window to repaint, causing constant visible flickering across all open windows every few seconds. Now on Windows only the single foreground window is captured using xcap's `is_focused()` check, with a monitor-capture fallback if that fails. + +### Build + +- **`clean:rust` script fails on Windows**: Replaced Unix `rm -rf` with a cross-platform Node script (`scripts/clean-rust.js`) that works on Windows, macOS, and Linux. The script now reports the size of build artifacts and how much disk space was freed. + +### UI + +- **Colored status dots for Emotiv Cortex**: The Scanner Backends section now shows a green dot when connected to the Cortex WebSocket, a blinking yellow dot while connecting, and a red dot when disconnected — replacing the previous static text badges. + +- **EMOTIV Launcher connection reminder**: Devices discovered via the Cortex transport (EMOTIV headsets) now show a hint reminding the user to confirm the connection in the EMOTIV Launcher app. + +- **Native shortcut rendering in tray menu**: Keyboard shortcuts in the system tray context menu now use the native accelerator parameter on `MenuItem` instead of being appended to the label text. The OS renders them right-aligned in the platform-native style (e.g. ⌘⇧L on macOS, Ctrl+Shift+L on Linux/Windows). + +### LLM + +- **`detect_garbled_tool_call()`**: New public function that identifies malformed tool-call attempts in assistant output (broken `[TOOL_CALL]` blocks, incomplete XML `` — search screenshots by visual similarity using CLIP vision embeddings (base64 image sent over WS, server-side CLIP embedding + HNSW search). + - `screenshots-for-eeg [--start --end] [--window N]` — find screenshots captured near EEG recording timestamps ("EEG → screen" bridge). Auto-selects the latest session when no range is given. + - `eeg-for-screenshots "query" [--k N] [--window N]` — search screenshots by OCR text, then return EEG labels and session info near each match ("screen → EEG" bridge). + - New WS commands: `search_screenshots_by_image_b64`, `search_screenshots_vision`, `screenshots_for_eeg`, `eeg_for_screenshots`. + - All commands support `--json`, `--mode`, `--k`, `--window`, and `--limit` flags. + +- **Implicit evidence collection for protocols**: Every protocol execution now produces structured measurement data using a standardised `px:start:`/`px:end:` label schema with pipe-separated metrics (bar, stress_index, relaxation, focus, hr, mood, faa, rmssd, deltas, outcome). The LLM captures before/after snapshots automatically and silently for every protocol, determining outcome as positive (≥10% target improvement), neutral, or negative. No user action required — evidence collection is invisible infrastructure. + +- **Personal protocol effectiveness ranking**: After 5+ labeled protocol executions, the LLM aggregates outcomes via `search_labels "px:end"` to build a personal ranking by success rate and average metric delta. Surfaces time-of-day patterns, trigger-specific effectiveness, and modality preferences. Presents insights like "Cold water face splash is your most effective stress intervention — 92% success, average stress drop 31%." + +- **Evidence-driven protocol selection**: New matching guidance rule — "Evidence first." Before suggesting any protocol, the LLM checks past effectiveness data. Leads with proven personal winners over generic recommendations. Retires consistent failures after 4+ negative outcomes. Explores new protocols occasionally even with strong data. Tracks modality preferences across interventions. + +- **Implicit life-event labeling**: The LLM silently labels mentioned life events (caffeine intake, walks, meals, meetings, exercise, app switches, sleep quality) with EEG metric snapshots to build a complete personal effectiveness map beyond formal protocols. Privacy safeguards: full transparency if asked, user owns all local data, no inference of unmentioned events. + +- **Interactive graph screenshots**: Added screenshot nodes to the interactive 3D cross-modal search graph. When the "Screenshots" checkbox is enabled, screenshots are discovered via three strategies: semantic text search on the query (attached to the query node), semantic search on each text-label (attached to label nodes), and timestamp proximity ±30 min around EEG points. Screenshots are injected as proper connected graph nodes (cyan spheres with async-loaded thumbnail sprites), connected via `screenshot_link` edges, and fully participate in selection highlighting. Duplicates are suppressed by filename. +- **SVG/DOT export includes screenshots**: When screenshots are visible in the interactive graph, the exported SVG and DOT files now include screenshot nodes. The frontend regenerates the SVG/DOT via new `regenerate_interactive_svg` / `regenerate_interactive_dot` Tauri commands, passing the full augmented graph (including client-side screenshot nodes) to the backend layout engine. + +- **Interactive search: screenshot discovery and 3D visualization**: The interactive cross-modal search now discovers screenshots near EEG neighbor timestamps, ranking them by window-title and OCR-text proximity to the query. Screenshot nodes appear as a new layer in the graph with dedicated styling (pink `#ec4899`). Two new edge kinds (`screenshot_prox` for temporal proximity and `ocr_sim` for text-based matches) connect EEG points to relevant screenshots. A new 3D perspective-projected SVG (`svg_3d`) is generated alongside the existing 2D layouts using 3-component PCA across all text embeddings. The `InteractiveGraphNode` struct gains `proj_z`, `filename`, `app_name`, `window_title`, `ocr_text`, and `ocr_similarity` fields. The `InteractiveSearchResult` includes the new `svg_3d` field. Both the Tauri command and WebSocket `interactive_search` handler are updated. DOT and flat SVG exports also render screenshot nodes. + +- **Modality Router**: Added a decision table mapping 12 EEG triggers to 7 intervention modalities (Breath, Tactile, Cognitive, Visual, Movement, Auditory, Passive Physiological) so the LLM selects the best modality for each person's circumstances before choosing a specific protocol. Breathing is presented as one equal option among many, never the default. Includes a modality selection guide by context and phrasing examples showing how to offer multi-modal choices. + +- **Multi-modal protocol restructuring**: Restructured ~30 protocols that previously defaulted to breathing to present non-breathing alternatives at equal priority using ", OR " choice points. Affected protocols include Focus Reset, Pre-Performance Activation, Extended Exhale, Physiological Sigh, Kapalabhati, 4-Count Energising, Wim Hof, Anger Processing, Grief Holding, Emotion Surfing, Joy Amplification, Emotional Boundaries, Excitement Regulation, all morning routines, all workout protocols, social media protocols, Pre-Meal Pause, Sleep Wind-Down, Co-Regulation, One-Handed Calm, Cortical Quieting, Coherence Building, Break Reset, and Post-Scroll Reset. + +- **Matching Guidance updated**: Added "Modality first, protocol second" as the top matching rule and "Always offer at least two modalities" as a new requirement, ensuring the LLM never prescribes breathing without presenting alternatives. + +- **neuroskill-evidence standalone skill**: New skill defining the implicit evidence collection and personal effectiveness engine used across all intervention-delivering skills. Includes the standardised `px:` label schema (`px:start`, `px:end`, `px:note`, `px:skip`, `px:auto`) with pipe-separated key=value context format, required fields (8 core EEG metrics), outcome determination rules by trigger type, mandatory before/after measurement flow, life-event implicit labeling (caffeine, meals, walks, meetings, sleep, exercise, app switches), hook trigger tracking, evidence aggregation by data maturity, personal protocol ranking algorithm (success_rate × avg_delta_target), 10 evidence-driven selection rules, evidence surfacing and tone guidelines, privacy safeguards, complete protocol name reference (100+ snake_case names), and a quick-reference integration pattern for other skills. + +- **Non-breathing protocol repertoire**: Added 30+ new protocols across 6 categories for users who find breathing exercises inconvenient — Cognitive Reset (micro-tasking, peripheral vision, mental arithmetic, internal narration silence, gratitude, mental time travel, worry parking, category switching), Tactile & Haptic Regulation (texture scanning, pressure points, temperature contrast, bilateral tapping, structured fidget, palm press, hand warming), Oculomotor & Visual (saccade reset, candle gaze, colour hunting, slow tracking, panoramic vision, near-far focus), Micro-Movement / Discreet (isometric squeeze-release, jaw release, micro-walk, seated spinal wave, wrist/ankle circles, shoulder blade pinch, single-leg floor press), Auditory / Non-Music (sound mapping, humming, toning, environmental sound bath, whisper reading, rhythmic tapping), and Passive Physiological (dive reflex, deliberate yawning, chewing, gargling, wrist cooling, ear massage, tongue press, postural gravity drop). Updated matching guidance to prioritise non-breathing protocols when user preference or context indicates. + +- **Protocol API Integration Guide**: Added a comprehensive guide teaching the LLM how to use the NeuroSkill API at every phase of protocol delivery — a 4-phase lifecycle (Before: validate trigger and baseline with `status`, `search_labels`, `label`; During: voice-guide with `say`, protect with `dnd_set`, monitor with `status` polling, time with `timer`; After: measure deltas, `label` outcomes, `notify` summaries, `say` results; Longitudinal: `search_labels` to find past protocol instances, `compare` protocol vs non-protocol sessions, `hooks_set` for auto-triggers, `umap` for visual separation, `sleep` for sleep protocol impact). Includes a 16-tool quick reference table, concrete JSON payload examples for each phase, and 10 integration principles. + +- **Protocol API annotations (🔧)**: Grounded 17 key protocols with specific API call sequences — Theta-Beta Neurofeedback (tbr live feedback), Box Breathing (full lifecycle with auto-hook setup after 5 sessions), Cardiac Coherence (rmssd as live biofeedback), Loving-Kindness (faa shift tracking), Alpha Induction (real-time alpha feedback loop), Sleep Wind-Down (sleep staging comparison next morning), NSDR/Yoga Nidra (full voice-guided delivery via `say`), Flow State (minimal interruption + neural signature hooks), Micro-Tasking Focus (quick measurement), Study Focus Sprint (Pomodoro + data-driven scheduling), Morning Clarity (sleep summary + streak), Caffeine Timing (personal sensitivity mapping via labels), Digital Sunset (sleep_schedule-driven auto-trigger), Sensory Overload (immediate DND + pattern recognition), Hyperfocus Exit (stillness-based proactive hook), Between-Patient Reset (shift stress trajectory), and Post-Doom-Scrolling (app correlation + proactive nudge hooks). + +- **Protocol personalisation engine**: Added a top-level Personalisation Engine to the protocol repertoire with 12 adaptation dimensions (age, physical ability, neurodivergence, emotional state, cultural background, gender/identity, professional context, social context, location/time, language, need urgency, intimacy level), 10 adaptation rules, and 12 concrete example adaptations showing the same EEG trigger delivered differently for an executive, new mother, teenager, construction worker, elder, autistic teen, night-shift nurse, grieving person, person with ADHD, pregnant woman, wheelchair user, and ESL speaker. + +- **Context-specific protocol collections**: Added 11 new protocol sections (77 protocols total) for underserved user groups — Parenting & Caregiving, Elderly & Aging, Teens & Students, Neurodivergent-Friendly (ADHD, autism, OCD, RSD), Commuters & Travellers, Manual & Physical Workers, Healthcare & Shift Workers, Intimate & Relational, Accessibility-Adapted (visual/hearing/mobility/cognitive impairment, chronic pain), Culturally Diverse Practices (Chinese Wuqinxi, Mesoamerican Temazcal, Japanese Shinrin-Yoku, Hawaiian Ho'oponopono, African Ubuntu, Indian Bhramari, Aboriginal Dadirri, Tai Chi, Islamic Dhikr, Christian Centering Prayer, Hindu/Buddhist Mala meditation), and Situational Micro-Protocols (11 ultra-short real-moment interventions). + +- **Diverse contextual variants across existing protocols**: Enhanced Attention, Stress, Emotional Regulation, Grounding, Sleep, Music, and Dietary protocols with persona-specific variant examples (marked with ◈) covering children, elders, wheelchair users, breath-averse users, public settings, non-English speakers, introverts, athletes, and more. Expanded music and food suggestions to span Western, South Asian, East Asian, Latin, African, K-pop, Arabic, and vegan cultures. + +- **Status command enriched with usage analytics**: The `status` command now returns most-used apps (all-time, 24h, 7d), most frequent label texts (all-time, 24h, 7d), screenshot/OCR summary counts (total, with embedding, with OCR, with OCR embedding), top screenshot apps (all-time, 24h), and label text-embedding count. New fields: `apps`, `screenshots`, and expanded `labels` section in the JSON response. + +### Performance + +- **Eliminate PNG encode/decode round-trip in screenshot embed pipeline**: The capture thread now sends the pre-decoded `DynamicImage` directly to the embed thread instead of encoding it to PNG first. The embed thread calls fastembed's `embed_images()` with the `DynamicImage` directly, avoiding the CPU-intensive PNG encode (capture thread) → PNG decode (fastembed) round-trip. For LLM vision and OCR paths that still need encoded bytes, JPEG is produced lazily (~10× faster than PNG). OCR via `ocrs` now also operates on the decoded image buffer directly (`run_ocr_from_image`) instead of encoding to PNG and decoding again. + +### Bugfixes + +- **LLM screenshot tool commands**: Added `search_screenshots`, `screenshots_around`, `screenshots_for_eeg`, and `eeg_for_screenshots` to the `skill` tool's command enum, description, and alias resolution. The LLM can now invoke screenshot search commands correctly instead of failing with validation errors. Also maps CLI names (`search-images`, `screenshots-around`, etc.) to their WebSocket API equivalents. + +- **CPU FFT fallback for CI**: Added `rustfft`-based CPU fallback in `skill-eeg` behind a feature gate. The `gpu` feature (opt-in) uses `gpu-fft` with wgpu; without it, tests and headless CI environments use pure-Rust `rustfft`. Fixes 33 test failures on GPU-less CI runners. + +- **Lazy embedder retry**: When the text embedding model fails to initialise at startup (e.g. missing download, network error), semantic label search, interactive search, and re-embed commands now retry initialisation on demand instead of permanently returning "embedder not initialized". + +- **Fix text embedder model resolution**: `build_embedder` used `EmbeddingModel::from_str` which only matches debug variant names (e.g. `BGESmallENV15`), not the `model_code` strings persisted in settings (e.g. `Xenova/bge-small-en-v1.5`). Added `resolve_embedding_model()` that first looks up by `model_code` from the supported-models list, falling back to the variant-name parser. Applied to both model init and `set_embedding_model` validation. + +- **LLM skill tool args coercion**: When an LLM flattens command arguments to the top level (e.g. `{"command":"search_screenshots","query":"today"}` instead of `{"command":"search_screenshots","args":{"query":"today"}}`), the coercion layer now automatically wraps stray properties into the `args` object before validation. Also handles the common `"arguments"` misspelling as an alias for `"args"`. This prevents validation errors for all `skill` tool commands. + +- **Missing skill commands in tool enum**: Added `sleep_schedule`, `sleep_schedule_set`, `health_summary`, `health_query`, `health_metric_types`, `health_sync`, `search_screenshots_vision`, and `search_screenshots_by_image_b64` to the skill tool command enum, description, and `is_skill_api_command()`. These WS commands were functional but invisible to the LLM. + +- **Coerced tool arguments now reach execution**: The tool orchestrator validated and coerced LLM arguments (e.g. wrapping flat `query` into `args`) but discarded the coerced value before calling `execute_builtin_tool_call`. The executor re-parsed the original un-coerced string, so flattened skill args like `{"command":"search_screenshots","query":"today"}` passed validation but still failed at runtime because `args.get("args")` found nothing. Fixed both sequential and parallel execution paths to write coerced arguments back to `tc.function.arguments` before execution. + +- **Generic hyphenated CLI name resolution**: Added a generic fallback in `resolve_skill_alias` that converts any hyphenated tool name to underscored form and checks if it's a valid skill API command. This catches LLMs copying CLI names from docs (e.g. `search-labels`, `session-metrics`, `sleep-schedule`, `dnd-set`) without needing to enumerate every variant. + +- **Block LLM download/management commands**: Added `llm_download`, `llm_cancel_download`, `llm_pause_download`, `llm_resume_download`, `llm_refresh_catalog`, and `llm_logs` to the BLOCKED list in skill tool execution. These LLM self-management commands should not be callable from the LLM itself. + +- **Fix missing description warnings for root skill files**: Added YAML frontmatter with `name` and `description` to `skills/SKILL.md`, `skills/README.md`, and `skills/METRICS.md` so the skill discovery scanner no longer emits "description is required in frontmatter" warnings. + +### Refactor + +- **3D PCA utility**: Added `pca_3d()` function to `skill-commands` for 3-component power-iteration PCA, complementing the existing `pca_2d()`. +- **SVG 3D generator**: Added `generate_svg_3d()` to `skill-commands::graph` — renders a dark-themed perspective-projected SVG with depth cues (scale, opacity, drop shadows) and a grid floor. + +- **Extracted evidence collection from neuroskill-protocols**: Removed the ~190-line inline Evidence Collection section from the protocols skill and replaced it with a reference to the standalone neuroskill-evidence skill. The protocols skill now depends on the evidence skill for all measurement, labeling, and ranking rules, allowing any other skill to also follow the same evidence framework. + +- **Split neuroskill-protocols into 11 domain sub-skills**: The monolithic 1866-line protocols skill has been split into a slim 411-line hub (personalisation engine, API integration guide, modality router, matching guidance, sub-skill index) plus 11 contextually-loaded domain sub-skills: `neuroskill-protocols-focus` (136 lines), `neuroskill-protocols-stress` (101 lines), `neuroskill-protocols-emotions` (110 lines), `neuroskill-protocols-sleep` (54 lines), `neuroskill-protocols-body` (106 lines), `neuroskill-protocols-routines` (117 lines), `neuroskill-protocols-nutrition` (129 lines), `neuroskill-protocols-music` (79 lines), `neuroskill-protocols-digital` (71 lines), `neuroskill-protocols-breathfree` (279 lines), and `neuroskill-protocols-life` (537 lines). Each sub-skill loads independently based on the user's message domain, enabling efficient use with small-context-window LLMs. The hub always loads when protocol intent is detected, and sub-skills load contextually alongside the neuroskill-evidence skill. + +### Build + +- **Bump cleans Rust artifacts**: `npm run bump` now runs `npm run clean:rust` at the end to remove `src-tauri/target`, freeing disk space after the preflight checks. + +### CLI + +- **CLI v1.2.0**: Bumped version to reflect new cross-modal search capabilities. +- Fixed misplaced shebang line that prevented `npx tsx cli.ts` from running. + +### LLM + +- **Cross-modal query guide in tool description**: Added a decision guide to the `skill` tool description that maps question patterns to the correct command and direction (e.g. "What was on screen during EEG?" → `screenshots_for_eeg`, "How was my brain when I saw X?" → `eeg_for_screenshots`). This helps the LLM pick the right cross-modal bridging command instead of guessing. + +- **Status tool results formatted as readable text**: When the internal LLM calls the `status` command, the result is now converted from raw JSON to a human-readable text block with clear section headers (Device, Session, EEG Embeddings, Labels, Most Used Apps, Screenshots, Signal Quality, Current Scores, Hooks, Sleep, Recording History). This makes status output in the Chat window much easier to read for both the user and the model. + +### Docs + +- **Cross-modal screenshot↔EEG documentation**: Updated SKILL.md, neuroskill-screenshots skill, and skills index with documentation for `search-images --by-image`, `screenshots-for-eeg`, `eeg-for-screenshots` commands, cross-modal workflows, and all new WS endpoints. + +- **SKILL.md updated for new and updated functionality**: Documented the enriched `status` command response with `apps` (top apps by window switches, all-time/24h/7d), `labels.top_all_time`/`top_24h`/`top_7d` (most frequent label texts), `labels.embedded` count, and `screenshots` summary (total, with_embedding, with_ocr, with_ocr_embedding, top_apps). Updated `interactive` search documentation to cover the new 5th screenshot layer, `screenshot` node kind with `filename`, `app_name`, `window_title`, `ocr_text`, `ocr_similarity` fields, `proj_z` 3-D PCA coordinate, new `screenshot_prox` and `ocr_sim` edge kinds, and `svg_3d` pre-rendered 3-D perspective SVG output. Added the `skill` built-in LLM tool to the tool-calling table with supported and blocked command lists. + +- **Skills synced with SKILL.md changes**: Updated `neuroskill-labels` skill to document the 5th screenshot layer in interactive search (screenshot nodes with `filename`, `app_name`, `window_title`, `ocr_text`, `ocr_similarity` fields), new `screenshot_prox` and `ocr_sim` edge kinds, 3-D PCA projection fields (`proj_x`/`proj_y`/`proj_z`), and three SVG outputs (`svg`, `svg_col`, `svg_3d`). Updated `neuroskill-llm` skill with full `skill` tool documentation including supported commands, argument coercion, blocked self-management commands, and status formatting. Updated skill index description from 4-layer to 5-layer graph search. + +- **Cross-modal workflow examples in skills**: Replaced the bash-only Cross-Modal Workflows section in the screenshots SKILL.md with a direction-based query guide table and multi-step LLM tool-call examples (JSON). Added Cross-Modal Follow-Ups sections to the search and labels SKILL.md files showing how to chain commands across modalities. + +- **Screenshot & skills search tests**: Added comprehensive smoke tests for all 6 screenshot WS commands (`search_screenshots`, `screenshots_around`, `search_screenshots_vision`, `search_screenshots_by_image_b64`, `screenshots_for_eeg`, `eeg_for_screenshots`) covering semantic/substring modes, cross-modal EEG↔screenshot bridging, CLIP vision search, base64 image upload, error handling for missing/invalid fields, and result structure validation. Also added skills command rejection tests confirming Tauri-only commands are correctly rejected over WS/HTTP. + +- **LLM tool call examples in skills**: Added `## LLM Tool Calls` sections with concrete JSON examples to all skill SKILL.md files (screenshots, labels, search, sessions, sleep, hooks, DND, streaming). This helps the LLM use the correct `{"command": "...", "args": {...}}` format. + +- **Improved skill tool description**: Updated the `skill` tool description and `args` field description with explicit examples showing the `command` + `args` nesting pattern. Added SLEEP SCHEDULE and HEALTH command groups to the description. + +- **Status SKILL.md**: Updated to document the new `apps` (top apps by window switches), `labels.top_*` (most frequent label texts), and `screenshots` (OCR counts, top apps) fields in the status response. Added LLM Tool Calls section with guidance on using `status` for app usage queries. Fixed JSON response example to show correct field names (`switches`, `last_seen`, `last_used`). + +## [0.0.50] — 2026-03-21 + +### Features + +- **Chat model picker in titlebar**: Click the model name in the titlebar to switch between downloaded models without leaving the conversation. The dropdown shows all downloaded models grouped by family with size info, and seamlessly stops the current model and loads the selected one. + +- **LFM2.5 1.2B Instruct model**: Added LiquidAI/LFM2.5-1.2B-Instruct-GGUF to the LLM catalog with 7 quant variants (Q4_0, Q4_K_M, Q5_K_M, Q6_K, Q8_0, F16, BF16). Ultra-compact 1.2B-parameter text model that fits in under 2 GB VRAM. + +- **LFM2.5 1.2B Thinking model**: Added LiquidAI/LFM2.5-1.2B-Thinking-GGUF to the LLM catalog with 7 quant variants (Q4_0, Q4_K_M, Q5_K_M, Q6_K, Q8_0, F16, BF16). Ultra-compact 1.2B-parameter reasoning model with chain-of-thought capability. + +### Performance + +- **Screenshot capture pipeline ~3× faster**: Eliminated redundant image encode/decode round-trips in the capture thread. `resize_fit_pad` no longer encodes to PNG (deferred to embed-thread send); `encode_webp` operates on the already-decoded `DynamicImage` instead of re-decoding from bytes; Linux/Windows xcap capture skips the ~500ms PNG encoding entirely by passing the decoded RGBA image directly. Expected improvement: ~2.9s → ~0.8s per iteration on Linux. + +### Bugfixes + +- **Fix label_store tests failing with "readonly database"**: The `TempDir` was dropped at the end of the helper function, deleting the temporary directory before the SQLite connection could write to it. The `TempDir` handle is now kept alive for the duration of each test. + +### Build + +- **Bump aborts on warnings**: `npm run bump` now captures stdout and stderr from every preflight check step and scans for warning lines. If any warnings are detected, the bump is aborted before any files are modified. `cargo clippy` is invoked with `-D warnings` to promote Rust warnings to errors. +- **Bump mirrors CI checks**: preflight now runs all CI-equivalent steps — `npm test` (vitest), `cargo clippy` on all workspace crates (not just the app crate), and `cargo test --lib` on the same crate subset as CI — so issues are caught locally before pushing. +- **Bump checks libpipewire-0.3**: added `libpipewire-0.3` to the Linux system dependency preflight check so the missing `-dev` package is caught early with a clear install hint instead of a cryptic cargo build failure. + +### UI + +- **Language dropdown close on outside click**: The language picker dropdown now stays open after selecting a language (showing the updated checkmark), and reliably closes when clicking anywhere outside the dropdown. Switched to `pointerdown` capture for robust outside-click detection across all window areas including drag regions. + +- **Language flag padding**: Added padding around the language flag button in the title bar for better spacing across all windows. + +## [0.0.49] — 2026-03-21 + +### Bugfixes + +- **Fix a11y warnings in HistoryCalendar**: Replaced clickable `
` with a ` - {/each} -
- - - - - -
- - {t("appearance.theme")} - - - - - - -
- {t("appearance.colorMode")} -
- {#each THEME_OPTIONS as opt} - - {/each} -
-
- - -
- -
- -
-
-
- - -
- - {t("appearance.chartColors")} - - - - -

- {t("appearance.chartColorsDesc")} -

- -
- {#each CHART_SCHEMES as scheme (scheme.id)} - - {/each} -
-
-
-
- - diff --git a/src/lib/BandChart.svelte b/src/lib/BandChart.svelte deleted file mode 100644 index cd10bdd05..000000000 --- a/src/lib/BandChart.svelte +++ /dev/null @@ -1,341 +0,0 @@ - - - - - - - -
- -
diff --git a/src/lib/CalibrationTab.svelte b/src/lib/CalibrationTab.svelte deleted file mode 100644 index a848fe13d..000000000 --- a/src/lib/CalibrationTab.svelte +++ /dev/null @@ -1,496 +0,0 @@ - - - - - -
- - - {#if !editing} - -
- {t("calibration.profiles")} - -
- -
- {#each profiles as p} - {@const isActive = p.id === activeId} -
-
- - - - -
- {p.name} -
- {#each p.actions as a} - - {a.label} - - {/each} - - {t("settings.nLoops", { n: p.loop_count })} · {p.break_duration_secs}s {t("calibration.breakLabel")} - -
- {#if p.last_calibration_utc} - - {t("calibration.lastAtAgo", { date: fmtDate(p.last_calibration_utc), ago: timeAgo(p.last_calibration_utc) })} - - {:else} - - {t("calibration.neverCalibrated")} - - {/if} -
- - -
- - {#if profiles.length > 1} - - {/if} -
-
-
- {/each} -
- - -
- - - {activeProfile ? `"${activeProfile.name}"` : ""} - -
- - {:else} - - -
- - - {isNew ? t("calibration.newProfile") : t("calibration.editProfile")} - -
- - -
- - {t("calibration.presets")} - -
- {#each PRESETS as preset} - - {/each} -
-
- - - - - -
- - -
- - -
-
- {t("settings.actionLabels")} - -
- - {#each editing.actions as action, i} -
- -
- - -
- - - -
- {#each [5,10,15,20,30] as secs} - - {/each} -
- - {#if editing.actions.length > 1} - - {/if} -
- {/each} -
- - -
- {t("settings.timing")} -
-
- - {t("settings.breakDurationSecs")} - -
- {#each [3,5,10,15,20] as secs} - - {/each} -
-
-
- - {t("settings.iterations")} - -
- {#each [1,2,3,5,10] as n} - - {/each} -
-
-
-
- - -
- -
- - -
- - {t("settings.summary")} - - {#each editing.actions as action, i} - {@const colors = ["bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", - "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20", - "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", - "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", - "bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20"]} - - {action.label || "?"} - - {/each} - - {t("settings.nLoops", { n: editing.loop_count })} - - - {t("settings.totalSecs", { n: totalSecs })} - -
- -
-
- - -
- - -
- - {/if} - -
diff --git a/src/lib/CommandPalette.svelte b/src/lib/CommandPalette.svelte index ed1200709..a70772b9b 100644 --- a/src/lib/CommandPalette.svelte +++ b/src/lib/CommandPalette.svelte @@ -10,250 +10,798 @@ the Free Software Foundation, version 3 only. --> Supports fuzzy text filtering and keyboard navigation. --> {#if open} @@ -271,7 +819,7 @@ the Free Software Foundation, version 3 only. --> class="fixed top-[15%] left-[50%] z-[9999] w-full max-w-[480px] translate-x-[-50%] rounded-2xl border border-border dark:border-white/[0.1] - bg-white dark:bg-[#18181f] shadow-2xl + bg-surface-1 shadow-2xl flex flex-col overflow-hidden" transition:fade={{ duration: 100 }} role="dialog" @@ -292,13 +840,13 @@ the Free Software Foundation, version 3 only. --> onkeydown={handleInputKeydown} type="text" placeholder={t("cmdK.placeholder")} - class="flex-1 bg-transparent text-[0.82rem] text-foreground + class="flex-1 bg-transparent text-ui-lg text-foreground placeholder:text-muted-foreground/50 focus:outline-none" spellcheck="false" autocomplete="off" /> - Esc @@ -306,17 +854,19 @@ the Free Software Foundation, version 3 only. -->
- {#if filtered.length === 0} -

+ {#if scored.length === 0} +

{t("cmdK.noResults")}

{:else} {#each sections as [sectionLabel, cmds], sIdx} -
-

- {sectionLabel} -

-
+ {#if sectionLabel} +
+

+ {sectionLabel} +

+
+ {/if} {#each cmds as cmd, cIdx} {@const fi = flatIndex(sIdx, cIdx)} @@ -334,10 +884,22 @@ the Free Software Foundation, version 3 only. --> aria-selected={fi === active} tabindex="-1" > - {cmd.icon} - {cmd.label} + {cmd.icon} + + {#each highlightSegments(cmd.label, cmd.labelPositions) as seg} + {#if seg.hi}{seg.t}{:else}{seg.t}{/if} + {/each} + + {#if cmd.toggle} + + {cmd.toggle.get() ? "ON" : "OFF"} + + {/if} {#if cmd.shortcut} - {cmd.shortcut} @@ -350,9 +912,12 @@ the Free Software Foundation, version 3 only. -->
+ text-ui-xs text-muted-foreground/50"> ↑↓ {t("cmdK.navigate")} ↵ {t("cmdK.run")} + ⇥ Toggle + @ settings + > commands {t("cmdK.footerHint")}
diff --git a/src/lib/CustomTitleBar.svelte b/src/lib/CustomTitleBar.svelte new file mode 100644 index 000000000..01cfe2027 --- /dev/null +++ b/src/lib/CustomTitleBar.svelte @@ -0,0 +1,868 @@ + + + + + + + +{#snippet iconChevronLeft()} + + + +{/snippet} +{#snippet iconChevronRight()} + + + +{/snippet} +{#snippet iconClose()} + + + +{/snippet} +{#snippet iconMaximize()} + + + +{/snippet} +{#snippet iconMinimize()} + + + +{/snippet} + + +{#snippet tbBtn(title: string, label: string, onclick: () => void, iconPath: string, activeClass?: string)} + +{/snippet} + + +{#snippet windowControls(order: "mac" | "win")} +
+ {#if order === "mac"} + + + + {:else} + + + + {/if} +
+{/snippet} + + +{#snippet centerContent()} + {#if isSearchWindow} +
+ +
+ {:else if isDownloadsWindow} + +
+ {t("downloads.windowTitle")} + {t("downloads.subtitle")} +
+ {:else if isHistoryWindow} +
+ {@render historyHead()} +
+ {:else if isHelpWindow} + +
+
+ + + {#if helpTitlebarState.query} + + {/if} +
+ v{helpTitlebarState.version} + {t("settings.license")} +
+ {:else if isChatWindow} +
+ + {#if chatTitlebarState.status === 'running' && chatTitlebarState.modelName} + + {:else} + + + {#if chatTitlebarState.status === 'loading'} + {t("chat.status.loading")} + {:else if chatTitlebarState.status === 'running'} + {t("chat.status.running")} + {:else} + {t("chat.status.stopped")} + {/if} + + {/if} +
+ {:else} + +
+ {#if !isMainWindow}{windowTitle}{/if} +
+ {/if} +{/snippet} + + +{#snippet actionButtons()} +
+ {#if isMainWindow} + {@render tbBtn("Add Label", "Add Label", openLabel, + '')} + {@render tbBtn("History", "History", openHistory, + '')} + {:else if isHistoryWindow} + {@render tbBtn( + hBar.compareMode ? t("history.exitCompare") : t("history.compare"), + "Compare", hCbs.toggleCompare, + '', + hBar.compareMode ? 'text-blue-500 bg-blue-500/10' : undefined + )} + {#if hBar.compareMode && hBar.compareCount >= 2} + + {/if} + {@render tbBtn(t("history.labels"), "Labels", hCbs.toggleLabels, + '', + hBar.showLabels ? 'text-amber-500 bg-amber-500/10' : undefined + )} + {@render tbBtn("Reload", "Reload", hCbs.reload, + '')} + {:else if isSettingsWindow} + {@render tbBtn("Help", "Help", openHelp, + '')} + {:else if isApiWindow} + {@render tbBtn(t("apiStatus.refresh"), t("apiStatus.refresh"), emitApiRefresh, + '')} + {/if} + + + +
+{/snippet} + + +{#snippet historyHead()} +
+ {#each HISTORY_VIEW_MODES as mode} + + {/each} +
+ + {#if hBar.viewMode === "day"} +
+ + + {#if hBar.daysLoading}{:else}{hBar.currentDayLabel || "—"}{/if} + + + {#if !hBar.daysLoading && hBar.dayCount > 0} + {hBar.currentDayIdx + 1}/{hBar.dayCount} + {/if} +
+ {:else} +
+ + {hBar.calendarLabel || "—"} + +
+ {/if} +{/snippet} + + + + +
+ {#if isMac} + {@render windowControls("mac")} + {@render centerContent()} + +
+ {@render actionButtons()} + {:else} + {#if isMainWindow} + {@render actionButtons()} + {/if} + {@render centerContent()} + +
+ {#if !isMainWindow} + {@render actionButtons()} + {/if} + {@render windowControls("win")} + {/if} + + {#if isLabelWindow && labelTitlebarState.active} + +
+ {t("label.eegWindow", { elapsed: labelTitlebarState.elapsed })} +
+ {/if} +
+ + +{#if modelPickerOpen && downloadedModels.length > 0} + + +
e.stopPropagation()}> + {#each pickerGroups as group} + {#if pickerGroups.length > 1} +
{group.family}
+ {/if} + {#each group.entries as entry} + {@const isActive = entry.filename === activeFilename} + {@const hasVision = visionRepos.has(entry.repo)} + + {/each} + {/each} +
+{/if} + + diff --git a/src/lib/DisclaimerFooter.svelte b/src/lib/DisclaimerFooter.svelte index fa68b32d1..7c48306f5 100644 --- a/src/lib/DisclaimerFooter.svelte +++ b/src/lib/DisclaimerFooter.svelte @@ -6,20 +6,20 @@ it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. -->
-

+

{t("disclaimer.copyright", { year: new Date().getFullYear() })}

@@ -31,24 +31,24 @@ the Free Software Foundation, version 3 only. --> role="presentation" onclick={(e) => { if (e.target === e.currentTarget) open = false; }} onkeydown={(e) => { if (e.key === "Escape") open = false; }}> -
⚠️ - + {t("disclaimer.title")}
-

+

{t("disclaimer.body")}

-

+

{t("disclaimer.nonCommercial")}

diff --git a/src/lib/EegModelTab.svelte b/src/lib/EegModelTab.svelte deleted file mode 100644 index c3a172d7b..000000000 --- a/src/lib/EegModelTab.svelte +++ /dev/null @@ -1,537 +0,0 @@ - - - - - - -
-
- - {t("model.encoder")} - - - {#if isDownloading} - - - {:else if modelStatus.encoder_loaded} - - {:else if needsRestart} - - {:else} - - {/if} -
- - - - - - {#if modelStatus.encoder_loaded} -
-
- {t("model.zunaEncoder")} - {#if modelStatus.encoder_describe} - - {modelStatus.encoder_describe} - - {/if} -
- - {t("model.ready")} - -
- - - {:else if isDownloading} -
-
-
- {t("model.zunaEncoder")} - - {modelStatus.download_status_msg ?? t("model.downloading")} - -
- - {t("model.downloading")} - -
- - -
-
-
- - - {#if modelStatus.download_status_msg} -

- {modelStatus.download_status_msg} -

- {/if} - - -
- -
-
- - - {:else if isAutoRetrying} -
-
-
- {t("model.zunaEncoder")} - - {t("model.autoRetryIn", { secs: String(modelStatus.download_retry_in_secs) })} - · {t("model.autoRetryAttempt", { n: String(modelStatus.download_retry_attempt + 1) })} - -
- - {t("model.unavailable")} - -
- {#if modelStatus.download_status_msg && modelStatus.download_status_msg !== "Download cancelled."} -

- {modelStatus.download_status_msg} -

- {/if} -
-
- - - - - - {modelStatus.download_retry_in_secs} - -
-

- {t("model.downloadFailed")} — {t("model.autoRetryIn", { secs: String(modelStatus.download_retry_in_secs) })} -

-
-
- - -
-
- - - {:else if hasFailed} -
-
-
- {t("model.zunaEncoder")} - - {t("model.downloadFailed")} - -
- - {t("model.unavailable")} - -
- - {#if modelStatus.download_status_msg} -

- {modelStatus.download_status_msg} -

- {/if} -
- -
-
- - - {:else if wasCancelled} -
-
- {t("model.zunaEncoder")} - {t("model.downloadCancelled")} -
- -
- - - {:else if needsRestart} -
-
-
- {t("model.zunaEncoder")} - - {t("model.restartToLoad")} - -
- - {t("model.ready")} - -
- -
-
-
-
- -
-
- - - {:else if encoderLoading} -
-
-
- {t("model.zunaEncoder")} - {t("model.encoderLoading")} -
- - {t("common.loading")} - -
-
-
-
-
- - - {:else} -
-
-
- {t("model.zunaEncoder")} - - {t("model.notFoundInCache")} - -
- - {t("model.unavailable")} - -
- -
-
- - {modelConfig.hf_repo} - -
- -
-
- {/if} - - - {#if modelStatus.weights_path} -
- {t("model.weightsPath")} - - {modelStatus.weights_path} - -
- {/if} - - -
-
- - {t("model.todaysDb")} - - - {modelStatus.daily_db_path || "—"} - -
-
- - {t("model.embeddingsToday")} - - - {modelStatus.embeddings_today} - -
-
- -
-
-
- - -
-
- - {t("model.hnswIndex")} - - {#if modelConfigSaving} - {t("common.saving")} - {/if} - {t("model.takesEffectRestart")} -
- - - - - -
-
- - {t("model.connectivity")} M - - {t("model.currently", { n: modelConfig.hnsw_m })} -
-

- {t("model.connectivityDesc")} -

-
- {#each HNSW_M_PRESETS as m} - - {/each} -
-
- - -
-
- - {t("model.buildQuality")} ef - - {t("model.currently", { n: modelConfig.hnsw_ef_construction })} -
-

- {t("model.buildQualityDesc")} -

-
- {#each HNSW_EF_PRESETS as ef} - - {/each} -
-
- - -
- {t("model.index")} - - M = {modelConfig.hnsw_m} - - - ef = {modelConfig.hnsw_ef_construction} - - - {t("model.cosineDistance")} - -
- -
-
-
- - -
- - {t("model.modelSource")} - - - - - -
- {t("model.hfRepo")} - {modelConfig.hf_repo} -
- -
-
- {t("model.dataNormalisation")} - {t("model.dataNormDesc")} -
- {modelConfig.data_norm} -
- -
-
-
diff --git a/src/lib/ElectrodeGuide.svelte b/src/lib/ElectrodeGuide.svelte deleted file mode 100644 index 63e6a9bd5..000000000 --- a/src/lib/ElectrodeGuide.svelte +++ /dev/null @@ -1,308 +0,0 @@ - - - - - -
- - -
- {#each TABS as tab} - - {/each} - {#if activeTab !== "muse"} - - {getElectrodes(activeTab as ElectrodeSystem).length} electrodes - - {/if} -
- - - {#if activeTab === "muse"} -
- {#each museChannels as name, idx} - {@const q = effectiveQuality ? effectiveQuality[idx] : 0} - {@const label = effectiveLabels[idx]} - {@const el = visible.find(e => e.name === name)} - - {/each} -
- {:else} - -
- Muse signal - {#each museChannels as name, idx} - {@const q = effectiveQuality ? effectiveQuality[idx] : 0} - {@const label = effectiveLabels[idx]} -
- - {name} -
- {/each} -
- {/if} - - - - -
- {#if Head3D} - - - - {:else} -
- Loading 3D view… -
- {/if} - - -
- {#each Object.entries(regionColors) as [region, color]} - {#if region !== "reference"} - - - {regionLabels[region as BrainRegion]} - - {/if} - {/each} -
- - -
- Drag to rotate · Click electrode -
-
- - - {#if selectedElectrode} - {@const el = selectedElectrode} -
-
- - {el.name} - {#if el.muse} - Muse - {/if} - {regionLabels[el.region]} - {#if el.muse && effectiveQuality} - {@const chIdx = museChannels.indexOf(el.name)} - {#if chIdx >= 0} - {@const q = effectiveQuality[chIdx]} - - Signal: {labelToText(effectiveLabels[chIdx])} - - {/if} - {/if} - -
-
-
- Position -

{el.lobe} — {el.function}

-
-
- Signals -

{el.signals}

-
- {#if el.museRole} -
- Muse Role -

{el.museRole}

-
- {/if} -
-
- {/if} -
diff --git a/src/lib/ElectrodeHead3D.svelte b/src/lib/ElectrodeHead3D.svelte deleted file mode 100644 index af1ca68bb..000000000 --- a/src/lib/ElectrodeHead3D.svelte +++ /dev/null @@ -1,459 +0,0 @@ - - - - - ref?.lookAt(0, 0, 0)} -> - - - - - - - - - - { group = ref; }}> - {#if gltfScene} - - {/if} - - - - {#if modelLoaded} - - {/if} - - - - {#if selRing} - { - if (selRing) { - const target = selRing.position.clone().add(selRing.normal); - ref.lookAt(target.x, target.y, target.z); - } - }} - /> - {/if} - - diff --git a/src/lib/ElectrodePlacement.svelte b/src/lib/ElectrodePlacement.svelte deleted file mode 100644 index 3f508ff84..000000000 --- a/src/lib/ElectrodePlacement.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - diff --git a/src/lib/EmbeddingsTab.svelte b/src/lib/EmbeddingsTab.svelte deleted file mode 100644 index 7afd39255..000000000 --- a/src/lib/EmbeddingsTab.svelte +++ /dev/null @@ -1,215 +0,0 @@ - - - - -
- - -
-
- - {t("embeddings.model")} - - -
- - - - - {#if activeModel} -
-
-
- - {activeModel.code} - - - {activeModel.dim}d - -
-

- {activeModel.description} -

-
-
- {/if} -
- - -
- - - - - -

- {t("embeddings.info")} -

-
- - -
-
-
-
- - {t("embeddings.reembed")} - - {#if staleCount > 0} - - {staleCount} {t("embeddings.stale")} - - {/if} -
- - {t("embeddings.reembedDesc")} - -
- -
- - - {#if progress !== null} - {@const pct = progress.total > 0 ? Math.round(progress.done / progress.total * 100) : 0} -
-
-
-
- - {progress.done} / {progress.total} — {pct}% - -
- {/if} -
- - -
- - {t("embeddings.dimLegend")} - - {#each [[384,"≤384d","emerald"],[768,"≤768d","blue"],[1024,"≤1024d","violet"]] as [,label,color]} - - {label} - - {/each} - {t("embeddings.dimHint")} -
- -
diff --git a/src/lib/GoalsTab.svelte b/src/lib/GoalsTab.svelte deleted file mode 100644 index 48e9819b9..000000000 --- a/src/lib/GoalsTab.svelte +++ /dev/null @@ -1,848 +0,0 @@ - - - - - -
- - -
-
- 🎯 -
-
- {t("goals.title")} - - {t("goals.subtitle")} - -
- -
- - {dailyGoalMin}m - - - {goalHours >= 1 ? `${goalHours.toFixed(1)} hours` : `${dailyGoalMin} minutes`} / day - - {#if streak > 0} - - 🔥 {streak}d streak - - {/if} -
-
- - - - - -
-
- {t("goals.targetMinutes")} - {dailyGoalMin}m -
- -
- 5m - 1h - 2h - 4h - 8h -
-
- - -
- - {t("goals.presets")} - -
- {#each PRESETS as [label, val]} - - {/each} -
-
- -
-
- - - - - -
- {t("goals.chartTitle")} - {#if loading} - {t("common.loading")} - {/if} -
- - {#if chartDays.length} - -
- -
- - {fmtMins(dailyGoalMin)} - -
- - -
- {#each chartDays as day, i} - {@const pct = Math.min(100, (day.minutes / chartMax) * 100)} - {@const color = barColor(day.minutes)} - {@const isToday = i === chartDays.length - 1} -
- - {#if day.minutes > 0} -
- - {#if isToday} -
- {/if} -
- {:else} -
- {/if} - -
- {day.label} -
{fmtMins(day.minutes)} - {#if day.minutes >= dailyGoalMin}{/if} -
-
- {/each} -
-
- - -
- {chartDays[0]?.label ?? ""} - {chartDays[Math.floor(chartDays.length / 2)]?.label ?? ""} - {t("goals.today")} -
- - -
- - - {t("goals.legendGoalMet")} - - - - {t("goals.legendHalfway")} - - - - {t("goals.legendSomeProgress")} - -
- {:else if !loading} -

- {t("goals.noData")} -

- {/if} - -
-
- - -
- - {t("goals.howItWorks")} - -
    -
  • - 📊 - {t("goals.info1")} -
  • -
  • - 🔔 - {t("goals.info2")} -
  • -
  • - 🔥 - {t("goals.info3")} -
  • -
-
- - -
- -
- - {t("dnd.section")} - - - {#if dndConfig.enabled} - - {dndActive ? t("dnd.statusActive") : t("dnd.statusInactive")} - - {/if} - - {#if dndOsActive !== null} - - {dndOsActive ? "System: ON" : "System: OFF"} - - {/if} - {#if dndSaving} - saving… - {/if} -
- - -
- - - - - {#if dndConfig.enabled} - -
-
- - {t("dnd.threshold")} (engagement) - - - {Math.round(dndConfig.focus_threshold)} - -
-

- {t("dnd.thresholdDesc")} -

- setDndThreshold(Number((e.currentTarget as HTMLInputElement).value))} - class="w-full accent-violet-500 h-2" /> -
- 10 - 40 - 60 - 80 - 95 -
-
- - -
-
- - {t("dnd.duration")} - - - {dndConfig.duration_secs}s - -
-

- {t("dnd.durationDesc")} -

-
- {#each DND_DURATION_PRESETS as [label, secs]} - - {/each} -
-
- - -
-
- - {t("dnd.exitDuration")} - - - {t("dnd.exitDurationValue", { min: String(Math.round(dndConfig.exit_duration_secs / 60)) })} - -
-

- {t("dnd.exitDurationDesc")} -

-
- {#each DND_EXIT_PRESETS as [label, secs]} - - {/each} -
-
- - -
-
- - {t("dnd.focusLookback")} - - - {dndConfig.focus_lookback_secs >= 60 - ? t("dnd.focusLookbackValue_min", { min: String(Math.round(dndConfig.focus_lookback_secs / 60)) }) - : t("dnd.focusLookbackValue", { secs: String(dndConfig.focus_lookback_secs) })} - -
-

- {t("dnd.focusLookbackDesc")} -

-
- {#each DND_LOOKBACK_PRESETS as [label, secs]} - - {/each} -
-
- - - {#if focusModes.length > 0} -
-
- - {t("dnd.focusMode")} - - {#if !focusModesLoaded} - {t("dnd.focusModeLoading")} - {/if} -
-

- {t("dnd.focusModeDesc")} -

-
- {#each focusModes as mode} - - {/each} -
-
- {/if} - - - - - - - {@const isHeld = dndActive && dndExitHeldByLookback} - {@const isCounting = dndActive && !dndExitHeldByLookback && dndExitSecsRemain > 0} - {@const isActive = dndActive && !dndExitHeldByLookback && dndExitSecsRemain === 0} - - -
- - - {#if isHeld} - - - {:else if isCounting} - - - {:else if isActive} - - - {:else} - - {/if} - - - - - {#if isHeld} - {t("dnd.exitHeld", { ago: String(dndConfig.focus_lookback_secs) })} - {:else if isCounting} - {t("dnd.exitingFocusMode")} - {:else} - {dndActive ? t("dnd.statusActive") : t("dnd.statusInactive")} - {/if} - - - -
- - {focusModes.find(m => m.identifier === dndConfig.focus_mode_identifier)?.name ?? "Do Not Disturb"} - · ≥{Math.round(dndConfig.focus_threshold)} for {dndConfig.duration_secs}s - - {#if dndOsActive !== null} - - {#if dndOsActive && !dndActive} - ⚠ System Focus active (set externally) - {:else} - System: {dndOsActive ? "ON" : "OFF"} - {/if} - - {/if} -
-
- - - {#if isCounting || isHeld} - {@const totalSecs = dndConfig.exit_duration_secs} - {@const elapsedSecs = isCounting ? totalSecs - dndExitSecsRemain : 0} - {@const pct = isCounting ? Math.min(100, (elapsedSecs / totalSecs) * 100) : 0} - {@const mm = isCounting ? Math.floor(dndExitSecsRemain / 60) : 0} - {@const ss = isCounting ? dndExitSecsRemain % 60 : 0} -
- - -
- {#if isCounting} - - {String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")} - - - {t("dnd.untilExit")} - - {:else} - - - {t("dnd.exitHeld", { ago: String(dndConfig.focus_lookback_secs) })} - - {/if} -
- - -
- {#if isCounting} -
- {:else} - -
- {/if} -
- - -
- 0s - {#if totalSecs >= 120} - {Math.round(totalSecs / 2)}s - {/if} - {totalSecs}s -
-
- {/if} - {/if} - - - {#if dndActive} -
- {t("dnd.forceOff")} - -
- {/if} - -
-
- - -

- 🍎 - {t("dnd.requiresMacOS")} -

-
- -
diff --git a/src/lib/GpuBar.svelte b/src/lib/GpuBar.svelte deleted file mode 100644 index 4504bd26f..000000000 --- a/src/lib/GpuBar.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - - - - -{#if gpuStats !== null} -
- - - GPU - - - - {#if spark} - - - - - {/if} - - -
-
-
-
- - - - {(gpuStats.overall * 100).toFixed(0)}% - -
-{/if} diff --git a/src/lib/GpuChart.svelte b/src/lib/GpuChart.svelte deleted file mode 100644 index 25fbd388b..000000000 --- a/src/lib/GpuChart.svelte +++ /dev/null @@ -1,297 +0,0 @@ - - - - - -{#if visible} -
- -
- {t("gpu.title")} - - - {t("gpu.render")} {(gpuStats!.render * 100).toFixed(0)}% · {t("gpu.tiler")} {(gpuStats!.tiler * 100).toFixed(0)}% - - - {(gpuStats!.overall * 100).toFixed(0)}% - - - -
- - {#if !collapsed} -
- -
- {/if} - -
-{/if} - - diff --git a/src/lib/HelpFaq.svelte b/src/lib/HelpFaq.svelte deleted file mode 100644 index 3eb542872..000000000 --- a/src/lib/HelpFaq.svelte +++ /dev/null @@ -1,222 +0,0 @@ - - - - - -
- - -
-

{t("helpOld.trayIconStates")}

-

{t("helpOld.trayIconDesc")}

-
- {#each [ - { dot:"bg-slate-400", labelKey:"helpOld.greyDisconnected", descKey:"helpOld.greyDesc" }, - { dot:"bg-yellow-400 animate-spin", labelKey:"helpOld.spinningScanning", descKey:"helpOld.spinningDesc" }, - { dot:"bg-green-500", labelKey:"helpOld.greenConnected", descKey:"helpOld.greenDesc" }, - { dot:"bg-red-500", labelKey:"helpOld.redBtOff", descKey:"helpOld.redDesc" }, - ] as row} -
- -
- {t(row.labelKey)} - {t(row.descKey)} -
-
- {/each} -
-
- - -
-

{t("helpOld.btLifecycle")}

-

{t("helpOld.btLifecycleDesc")}

-
-
    - {#each [ - ["helpOld.btStep1", "helpOld.btStep1Desc"], - ["helpOld.btStep2", "helpOld.btStep2Desc"], - ["helpOld.btStep3", "helpOld.btStep3Desc"], - ["helpOld.btStep4", "helpOld.btStep4Desc"], - ["helpOld.btStep5", "helpOld.btStep5Desc"], - ] as [stepKey, descKey], i} -
  1. - {i + 1} -
    - {t(stepKey)} - {t(descKey)} -
    -
  2. - {/each} -
-
-
- - -
-

{t("helpOld.examples")}

-
- - {#each [ - { - titleKey: "helpOld.example1Title", - steps: [ - { color: "text-slate-400", icon: "⬤", textKey: "helpOld.ex1Step1" }, - { color: "text-yellow-400", icon: "⟳", textKey: "helpOld.ex1Step2" }, - { color: "text-green-500", icon: "⬤", textKey: "helpOld.ex1Step3" }, - ], - }, - { - titleKey: "helpOld.example2Title", - steps: [ - { color: "text-green-500", icon: "⬤", textKey: "helpOld.ex2Step1" }, - { color: "text-red-500", icon: "⬤", textKey: "helpOld.ex2Step2" }, - { color: "text-slate-400", icon: " ", textKey: "helpOld.ex2Step3", muted: true }, - { color: "text-yellow-400", icon: "⟳", textKey: "helpOld.ex2Step4" }, - { color: "text-green-500", icon: "⬤", textKey: "helpOld.ex2Step5" }, - ], - }, - { - titleKey: "helpOld.example3Title", - steps: [ - { color: "text-yellow-400", icon: "⟳", textKey: "helpOld.ex3Step1" }, - { color: "text-slate-400", icon: " ", textKey: "helpOld.ex3Step2", muted: true }, - { color: "text-yellow-400", icon: "⟳", textKey: "helpOld.ex3Step3" }, - { color: "text-green-500", icon: "⬤", textKey: "helpOld.ex3Step4" }, - ], - }, - ] as ex} -
-

- {t(ex.titleKey)} -

-
- {#each ex.steps as s} -
- {s.icon} - {t(s.textKey)} -
- {/each} -
-
- {/each} - -
-
- - -
-

{t("helpOld.wsTitle")}

-

{t("helpOld.wsDesc")}

-
-

- {t("helpOld.discoverService")} -

-
# macOS
-dns-sd -B _skill._tcp
-
-# Linux
-avahi-browse _skill._tcp
-

- {t("helpOld.broadcastEvents")} -

-
{`{
-  "event":   "eeg-bands" | "muse-status" | "label-created",
-  "payload": { … }
-}`}
-

- {t("helpOld.commands")} -

-
{`status     — device state, scores, sleep summary
-label      — attach a label to the current moment
-search     — find nearest EEG embeddings
-sessions   — list all recording sessions
-compare    — full comparison: metrics, sleep, UMAP
-sleep      — classify sleep stages for a time range
-umap       — enqueue 3D UMAP projection (non-blocking)
-umap_poll  — poll for UMAP result by job_id`}
-
-
- - -
-

{t("helpOld.faq")}

-
- {#each [ - ["helpOld.faqQ1", "helpOld.faqA1"], - ["helpOld.faqQ2", "helpOld.faqA2"], - ["helpOld.faqQ3", "helpOld.faqA3"], - ["helpOld.faqQ4", "helpOld.faqA4"], - ["helpOld.faqQ5", "helpOld.faqA5"], - ["helpOld.faqQ6", "helpOld.faqA6"], - ["helpOld.faqQ7", "helpOld.faqA7"], - ["helpOld.faqQ8", "helpOld.faqA8"], - ["helpOld.faqQ9", "helpOld.faqA9"], - ["helpOld.faqQ10", "helpOld.faqA10"], - ["helpOld.faqQ11", "helpOld.faqA11"], - ["helpOld.faqQ12", "helpOld.faqA12"], - ["helpOld.faqQ13", "helpOld.faqA13"], - ["helpOld.faqQ14", "helpOld.faqA14"], - ["helpOld.faqQ15", "helpOld.faqA15"], - ["helpOld.faqQ16", "helpOld.faqA16"], - ["helpOld.faqQ17", "helpOld.faqA17"], - ["helpOld.faqQ18", "helpOld.faqA18"], - ["helpOld.faqQ19", "helpOld.faqA19"], - ] as [qKey, aKey]} -
- - {t(qKey)} - - - - -

- {t(aKey)} -

-
- {/each} -
-
- -
- - diff --git a/src/lib/Hypnogram.svelte b/src/lib/Hypnogram.svelte deleted file mode 100644 index e1e9fc5b1..000000000 --- a/src/lib/Hypnogram.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - - - - -
- - - - -
- {#each STAGES as s} - {@const count = s.stage === 0 ? summary.wake_epochs - : s.stage === 5 ? summary.rem_epochs - : s.stage === 1 ? summary.n1_epochs - : s.stage === 2 ? summary.n2_epochs - : summary.n3_epochs} -
- - {t(s.key)} - - {stagePct(count)}% · {stageDur(count)} - -
- {/each} -
-
diff --git a/src/lib/InteractiveGraph3D.svelte b/src/lib/InteractiveGraph3D.svelte deleted file mode 100644 index 5f000efa9..000000000 --- a/src/lib/InteractiveGraph3D.svelte +++ /dev/null @@ -1,750 +0,0 @@ - - - - - - -
{ tooltip = null; hoveredNodeId = null; }} - role="img" aria-label="Interactive search 3D graph"> - - - {#if tooltip} -
- {#each tooltip.lines as line, li} -
- {line} -
- {/each} -
- {/if} - - - {#if selectedNodeId !== null} -
- click node again or click empty space to deselect -
- {/if} - - - {#if !loaded} -
-
-
- {/if} -
- - -
- - -
- {#each LEGEND_BASE as l} -
- - {l.label} -
- {/each} -
- - {foundLabelLegend} -
- · - {#each EDGE_LEGEND as l} -
- - {l.label} -
- {/each} - - hover · click to highlight · click again to clear - -
- - - {#if eegDots.length > 0} -
- - EEG node time scale - - - - - {#each Array.from({ length:12 }, (_,i) => i/11) as t} - - {/each} - - - - {#each eegDots as dot} - {@const x = dot.t * 500} - - - {/each} - {#each eegTicks as tick} - {@const x = tick.t * 500} - - - {tick.label} - - {/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/KeyboardShortcuts.svelte b/src/lib/KeyboardShortcuts.svelte index 83fda2d16..54e8dc74b 100644 --- a/src/lib/KeyboardShortcuts.svelte +++ b/src/lib/KeyboardShortcuts.svelte @@ -11,130 +11,182 @@ the Free Software Foundation, version 3 only. --> in-app keyboard shortcuts hardcoded in the frontend. --> {#if open} @@ -152,7 +204,7 @@ the Free Software Foundation, version 3 only. --> class="fixed top-[50%] left-[50%] z-[9999] w-full max-w-[420px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border dark:border-white/[0.08] - bg-white dark:bg-[#16161e] shadow-2xl + bg-surface-1 shadow-2xl p-5 flex flex-col gap-4" transition:fade={{ duration: 150 }} role="dialog" @@ -162,7 +214,7 @@ the Free Software Foundation, version 3 only. --> >
-

+

{t("shortcuts.title")}

diff --git a/src/lib/MarkdownRenderer.svelte b/src/lib/MarkdownRenderer.svelte new file mode 100644 index 000000000..4312f6685 --- /dev/null +++ b/src/lib/MarkdownRenderer.svelte @@ -0,0 +1,61 @@ + + + + + + +
+ {@html html} +
diff --git a/src/lib/OnboardingChecklist.svelte b/src/lib/OnboardingChecklist.svelte new file mode 100644 index 000000000..c640098fe --- /dev/null +++ b/src/lib/OnboardingChecklist.svelte @@ -0,0 +1,61 @@ + + + + + +
+
+ 🚀 + + {t("dashboard.gettingStarted")} + + + {doneCount}/{steps.length} + +
+ +
+
+
+
+
+ {#each steps as step (step.label)} +
+
+ {#if step.done} + + + + {/if} +
+ + {step.label} + +
+ {/each} +
+ +
diff --git a/src/lib/PairBrowserModal.svelte b/src/lib/PairBrowserModal.svelte new file mode 100644 index 000000000..7ffc123ac --- /dev/null +++ b/src/lib/PairBrowserModal.svelte @@ -0,0 +1,114 @@ + + + + + + +{#if visible} +
+
+
+ + + + + +

Pair browser extension

+
+ +

+ {clientName} wants to connect to NeuroSkill + and start tracking browsing activity for EEG correlation. +

+ +
+
• Domain & content type tracking (privacy-respecting)
+
• Brain state correlation (focus, distraction)
+
• You can revoke access anytime in Settings → Extensions
+
+ +
+ + +
+
+
+{/if} diff --git a/src/lib/PermissionsTab.svelte b/src/lib/PermissionsTab.svelte deleted file mode 100644 index b252fac58..000000000 --- a/src/lib/PermissionsTab.svelte +++ /dev/null @@ -1,322 +0,0 @@ - - - - - -
- - -
-

- {t("perm.intro")} -

-
- - - {#if isMac} -
- - {t("perm.accessibility")} - - - - - -
-
- ⌨️ - {t("perm.accessibility")} -
-
- - - {statusLabel(accessStatus)} - - -
-
- - -

- {t("perm.accessibilityDesc")} -

- - {#if accessStatus === "denied"} - -
- {t("perm.howToGrant")} -
    -
  1. {t("perm.accessStep1")}
  2. -
  3. {t("perm.accessStep2")}
  4. -
  5. {t("perm.accessStep3")}
  6. -
  7. {t("perm.accessStep4")}
  8. -
-
- {:else if accessStatus === "granted"} -

- {t("perm.accessibilityOk")} -

- {:else} -

- {t("perm.accessibilityPending")} -

- {/if} - -
- -
- -
-
-
- {/if} - - -
- - {t("perm.bluetooth")} - - - - -
-
- 📶 - {t("perm.bluetooth")} -
- - - {t("perm.systemManaged")} - -
- -

- {t("perm.bluetoothDesc")} -

- - {#if isMac} -
- -
- {/if} - -
-
-
- - -
- - {t("perm.notifications")} - - - - -
-
- 🔔 - {t("perm.notifications")} -
- - - {t("perm.systemManaged")} - -
- -

- {t("perm.notificationsDesc")} -

- -
- -
- -
-
-
- - -
- - {t("perm.matrix")} - - - - - - - - - - - - - - {#each [ - [t("perm.matrixBluetooth"), "✅ " + t("perm.matrixNone"), "✅ " + t("perm.matrixNone"), "✅ " + t("perm.matrixNone")], - [t("perm.matrixKeyboardMouse"), "🔑 " + t("perm.matrixAccessibility"), "✅ libxtst", "✅ " + t("perm.matrixNone")], - [t("perm.matrixActiveWindow"), "✅ " + t("perm.matrixNone"), "✅ xdotool", "✅ " + t("perm.matrixNone")], - [t("perm.matrixNotifications"), "⚙️ " + t("perm.matrixOsPrompt"), "✅ " + t("perm.matrixNone"), "⚙️ " + t("perm.matrixOsPrompt")], - ] as [feat, mac, linux, win]} - - - - - - - {/each} - -
{t("perm.feature")}macOSLinuxWindows
{feat}{mac}{linux}{win}
-
- ✅ {t("perm.legendNone")} - 🔑 {t("perm.legendRequired")} - ⚙️ {t("perm.legendPrompt")} -
-
-
-
- - -
- - {t("perm.why")} - - - -
-

🔵 {t("perm.whyBluetooth")} — {t("perm.whyBluetoothDesc")}

-

⌨️ {t("perm.whyAccessibility")} — {t("perm.whyAccessibilityDesc")}

-

🔔 {t("perm.whyNotifications")} — {t("perm.whyNotificationsDesc")}

-

{t("perm.privacyNote")}

-
-
-
-
- -
diff --git a/src/lib/SettingsTab.svelte b/src/lib/SettingsTab.svelte deleted file mode 100644 index ee14d46b2..000000000 --- a/src/lib/SettingsTab.svelte +++ /dev/null @@ -1,1433 +0,0 @@ - - - - - - -
- - {t("settings.museDevices")} - - - - {#if hasNewUnpaired} -
- 📡 -
- - {t("settings.newDeviceNotice")} - - - {t("settings.newDeviceNoticeHint")} - -
-
- {/if} - - - {#if devices.length === 0} - - 📡 -

{t("settings.noDevices")}

-

- {t("settings.powerOnMuse")} -

-
- {:else} - {#each devices as dev, i (dev.id)} - {#if i > 0}{/if} - -
- - - {#if museImage(dev.name, dev.hardware_version)} - {dev.name} - {:else} -
🧠
- {/if} - -
-
- {dev.name} - {#if dev.is_preferred} - - {/if} - {#if dev.is_paired} - - {t("settings.paired")} - - {:else if dev.last_rssi !== 0} - - {t("settings.new")} - - {/if} -
- -
- {dev.id} - {#if dev.last_rssi !== 0} - - {fmtRssi(dev.last_rssi)} - - {/if} - - {fmtLastSeen(dev.last_seen)} - -
- - - {#if !dev.is_paired} - - {t("settings.pairToConnect")} - - {/if} - - {#if dev.id === connected.device_id && (connected.serial_number || connected.mac_address)} -
- {#if connected.serial_number} - - {/if} - {#if connected.mac_address} - - {/if} -
- {/if} -
- -
- {#if dev.is_paired} - - - - {:else} - - - {/if} -
-
- {/each} - {/if} -
-
- - -
- - - {#if openbciExpanded} -

- {t("settings.openbciDesc")} -

- - - - - -
-

{t("settings.openbciBoard")}

-
- {#snippet boardRadio(val: OpenBciBoard, key: string)} - - {/snippet} - {@render boardRadio("ganglion", "settings.openbciBoardGanglion")} - {@render boardRadio("ganglion_wifi", "settings.openbciBoardGanglionWifi")} - {@render boardRadio("cyton", "settings.openbciBoardCyton")} - {@render boardRadio("cyton_wifi", "settings.openbciBoardCytonWifi")} - {@render boardRadio("cyton_daisy", "settings.openbciBoardCytonDaisy")} - {@render boardRadio("cyton_daisy_wifi", "settings.openbciBoardCytonDaisyWifi")} - {@render boardRadio("galea", "settings.openbciBoardGalea")} -
- - - {#if OPENBCI_IMAGES[openbci.board]} -
- {t(`settings.openbciBoard${openbci.board.replace(/_([a-z])/g, c.toUpperCase()).replace(/^./, s => s.toUpperCase())}`) || openbci.board} - class="h-36 max-w-full object-contain rounded-xl - bg-muted/30 dark:bg-white/[0.03] p-2 - border border-border dark:border-white/[0.06] - transition-all duration-200" /> -
- {/if} -
- - - - - {#if isBle} -
- - { openbciChanged = true; }} - class="w-16 text-[0.73rem] text-center px-2 py-1 rounded-md border border-border - bg-background text-foreground tabular-nums" /> - {t("settings.openbciScanTimeoutSuffix")} -
- - {/if} - - - {#if isSerial} -
-

{t("settings.openbciSerialPort")}

-
- {#if serialPorts.length > 0} - - {:else} - { openbciChanged = true; }} - placeholder={t("settings.openbciSerialPortPlaceholder")} - class="flex-1 min-w-0 text-[0.73rem] font-mono px-2 py-1 rounded-md border border-border bg-background text-foreground" /> - {/if} - -
- {t("settings.openbciSerialPortHint")} -
- - {/if} - - - {#if isWifi} -
- -
- OpenBCI WiFi Shield -

{t("settings.openbciWifiShieldIp")}

-
- { openbciChanged = true; }} - placeholder={t("settings.openbciWifiShieldIpPlaceholder")} - class="text-[0.73rem] font-mono px-2 py-1 rounded-md border border-border bg-background text-foreground" /> -
- - { openbciChanged = true; }} - class="w-20 text-[0.73rem] text-center px-2 py-1 rounded-md border border-border - bg-background text-foreground tabular-nums" /> -
-
- - {/if} - - - {#if isGalea} -
-

{t("settings.openbciGaleaIp")}

- { openbciChanged = true; }} - placeholder={t("settings.openbciGaleaIpPlaceholder")} - class="text-[0.73rem] font-mono px-2 py-1 rounded-md border border-border bg-background text-foreground" /> -
- - {/if} - - -
- - {t("settings.openbciChannelLabels")} - ({channelCount}) - - - {#if channelCount > 4} -

- {t("settings.openbciChannelsBeyond4", { n: channelCount })} -

- {/if} - - -
- - -
- - -
- {#each Array.from({ length: channelCount }, (_, i) => i) as i} -
- {i + 1} - setChannelLabel(i, (e.currentTarget as HTMLInputElement).value)} - placeholder={defaultChannelNames[i] ?? `Ch${i+1}`} - class="w-full min-w-0 text-[0.7rem] font-mono text-center px-1 py-0.5 rounded - border border-border bg-background text-foreground - placeholder:text-muted-foreground/35 - focus:outline-none focus:ring-1 focus:ring-blue-500/50" /> -
- {/each} -
- - {t("settings.openbciChannelLabelsHint")} -
- -
-
- - -
- - - {#if !isBle} - - {/if} -
- - {#if openbciError} -

{openbciError}

- {/if} - {/if} -
- - -
-
- - {t("settings.signalProcessing")} - - {#if filterSaving} - {t("common.saving")} - {/if} -
- - - - - -
- {t("settings.powerlineNotch")} -
- {#each ([["Hz60","🇺🇸",t("settings.us60Hz"),t("settings.us60HzSub")],["Hz50","🇪🇺",t("settings.eu50Hz"),t("settings.eu50HzSub")]] as const) as [val, flag, label, sub]} - - {/each} - - -
-
- - -
- {t("settings.highPassCutoff")} -
- {#each ([null, 0.5, 1, 4, 8] as const) as hz} - - {/each} -
-
- - -
- {t("settings.lowPassCutoff")} -
- {#each ([null, 30, 50, 100] as const) as hz} - - {/each} -
-
- - -
- - {t("settings.pipeline")} - - {#if filter.high_pass_hz !== null} - - HP {filter.high_pass_hz} Hz - - {/if} - {#if filter.low_pass_hz !== null} - - LP {filter.low_pass_hz} Hz - - {/if} - {#if filter.notch !== null} - - Notch {filter.notch === "Hz60" ? "60+120 Hz" : "50+100 Hz"} - - {/if} - {#if filter.high_pass_hz === null && filter.low_pass_hz === null && filter.notch === null} - - {t("settings.passthrough")} - - {/if} - {t("settings.gpuLatency")} -
- -
-
-
- - -
-
- - {t("settings.eegEmbedding")} - - {#if overlapSaving} - saving… - {/if} -
- - - - -
-
- {t("settings.epochOverlap")} - - {t("settings.everyNSecs", { n: (EMBEDDING_EPOCH_SECS - overlapSecs).toFixed(2).replace(/\.?0+$/, "") })} - -
-

- {t("settings.overlapDescription")} -

-
- {#each OVERLAP_PRESETS as [label, val]} - - {/each} -
-
- -
- - Pipeline - - - {EMBEDDING_EPOCH_SECS} s window - - - {overlapSecs} s overlap - - - {Math.round(overlapSecs / EMBEDDING_EPOCH_SECS * 100)}% shared - - ZUNA · wgpu -
- -
-
-
- - - -{#if gpuStats} - {@const fmtBytes = (b: number | null) => { - if (b === null || b <= 0) return null; - const gb = b / (1024 ** 3); - return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / (1024 ** 2)).toFixed(0)} MB`; - }} - {@const usedBytes = (gpuStats.totalMemoryBytes !== null && gpuStats.freeMemoryBytes !== null) - ? gpuStats.totalMemoryBytes - gpuStats.freeMemoryBytes : null} - {@const usedPct = (usedBytes !== null && gpuStats.totalMemoryBytes) - ? Math.round(usedBytes / gpuStats.totalMemoryBytes * 100) : null} - {@const memLabel = gpuStats.isUnifiedMemory ? "Unified Memory (RAM)" : "VRAM"} - -
- - GPU · {memLabel} - - - - - - - {#if gpuStats.totalMemoryBytes} -
-
- {memLabel} - {#if fmtBytes(gpuStats.totalMemoryBytes)} - - {fmtBytes(gpuStats.totalMemoryBytes)} - {#if gpuStats.isUnifiedMemory}total{/if} - - {/if} -
- - {#if usedPct !== null && gpuStats.freeMemoryBytes !== null} - -
-
-
-
-
- - {fmtBytes(usedBytes)} used - · - {fmtBytes(gpuStats.freeMemoryBytes)} free - - - {usedPct}% - -
- {:else if gpuStats.freeMemoryBytes} -

- {fmtBytes(gpuStats.freeMemoryBytes)} free -

- {/if} - - {#if gpuStats.isUnifiedMemory} -

- Apple Silicon uses a single unified memory pool shared by CPU and GPU. - "Free" includes inactive pages that can be reclaimed immediately. -

- {/if} -
- {/if} - - - {#if gpuStats.overall > 0 || gpuStats.render > 0 || gpuStats.tiler > 0} -
- - GPU Usage - - {#each ([ - ["Render", gpuStats.render], - ["Tiler", gpuStats.tiler], - ["Overall", gpuStats.overall], - ] as [string, number][]).filter(([, v]) => v > 0) as [label, val]} -
-
-
-
- - {label} {Math.round(val * 100)}% - -
- {/each} -
- {/if} - -
-
-
-{/if} - - -
-
- - {t("settings.activityTracking")} - - {t("settings.activityDb")} -
- - - - - - - - - {#if trackActiveWindow} -
- - {t("settings.activeWindowCurrent")} - - {#if currentActiveWindow} -
- {#each ([ - [t("settings.activeWindowApp"), currentActiveWindow.app_name, "font-semibold text-foreground"], - [t("settings.activeWindowTitle"), currentActiveWindow.window_title, "text-foreground/80"], - [t("settings.activeWindowPath"), currentActiveWindow.app_path, "font-mono text-muted-foreground"], - [t("settings.activeWindowSince"), fmtLastSeen(currentActiveWindow.activated_at), "text-muted-foreground"], - ] as [string, string, string][]).filter(([, v]) => v) as [label, value, cls]} -
- {label} - {value} -
- {/each} -
- {:else} -

{t("settings.activeWindowNone")}

- {/if} -
- {/if} - - - - - - - - {#if trackInputActivity} - {@const hasData = lastInputActivity[0] > 0 || lastInputActivity[1] > 0} -
- - -
- - {#if hasData} - - - {:else} - - {/if} - - - {hasData ? t("settings.inputActivityActive") : t("settings.inputActivityNoData")} - -
- - -
- {#each ([ - [t("settings.inputActivityKeyboard"), lastInputActivity[0]], - [t("settings.inputActivityMouse"), lastInputActivity[1]], - ] as [string, number][]) as [label, ts]} -
- {label} - - {ts > 0 ? fmtLastSeen(ts) : t("settings.inputActivityNever")} - -
- {/each} -
- - -

- {t("settings.inputActivityPermNote")} -

-
- {/if} - -
-
-
- - -
-
- - {t("settings.logging")} - - {dataDirCurrent}/log_config.json -
- - - -
- {#each ([ - ["embedder", t("settings.logEmbedder"), t("settings.logEmbedderDesc")], - ["bluetooth", t("settings.logBluetooth"), t("settings.logBluetoothDesc")], - ["websocket", t("settings.logWebsocket"), t("settings.logWebsocketDesc")], - ["csv", t("settings.logCsv"), t("settings.logCsvDesc")], - ["filter", t("settings.logFilter"), t("settings.logFilterDesc")], - ["bands", t("settings.logBands"), t("settings.logBandsDesc")], - ["history", t("settings.logHistory"), t("settings.logHistoryDesc")], - ] as [keyof LogConfig, string, string][]) as [key, label, desc]} - - {/each} -
-
-
-
- - -
- - {t("settings.dataDir")} - - - - -

- {t("settings.dataDirDesc")} -

- -
- - {t("settings.dataDirDefault", { path: dataDirDefault })} - -
- -
- { dataDirChanged = dataDirInput !== dataDirCurrent; }} - placeholder={dataDirDefault} - class="flex-1 h-7 rounded-md border border-border bg-background px-2 text-[0.68rem] - font-mono text-foreground placeholder:text-muted-foreground/40 - focus:outline-none focus:ring-1 focus:ring-ring" /> - {#if dataDirInput !== dataDirDefault} - - {/if} -
- - {#if dataDirChanged} -
- - {t("settings.dataDirRestart")} - - -
- {/if} -
-
-
- - -
- - {t("settings.wsConfig")} - - - - - - -
- {t("settings.wsHost")} -
- {#each [["127.0.0.1", t("settings.wsHostLoopback")], ["0.0.0.0", t("settings.wsHostLan")]] as [val, lbl]} - - {/each} -
- {#if wsHost === "0.0.0.0"} -

- {t("settings.wsHostDesc")} -

- {/if} -
- - - - -
- {t("settings.wsPort")} -

{t("settings.wsPortDesc")}

-
- { - const n = parseInt(wsPortInput, 10); - if (isNaN(n) || n < 1024 || n > 65535) { - wsPortError = t("settings.wsPortInvalid"); - wsPortChanged = false; - } else { - wsPortError = ""; - wsPortChanged = n !== wsPort; - } - }} - class="w-28 h-7 rounded-md border border-border bg-background px-2 text-[0.68rem] - font-mono text-foreground focus:outline-none focus:ring-1 focus:ring-ring" /> - {#if wsPortError} - {wsPortError} - {/if} -
-
- - - {#if wsChanged && !wsPortError} -
- - {t("settings.wsRestart")} - - -
- {/if} - -
-
-
- diff --git a/src/lib/ShortcutsTab.svelte b/src/lib/ShortcutsTab.svelte deleted file mode 100644 index 8b16ef8af..000000000 --- a/src/lib/ShortcutsTab.svelte +++ /dev/null @@ -1,301 +0,0 @@ - - - - - - - -
-
- - {t("settings.globalShortcuts")} - - {t("settings.hotkeys")} -
- - - - - {#each ([ - ["label", t("settings.shortcutAddLabel"), shortcut], - ["search", t("settings.shortcutSearch"), searchShortcut], - ["calibration", t("settings.shortcutCalibration"), calibrationShortcut], - ["settings", t("settings.shortcutSettings"), settingsShortcut], - ["help", t("settings.shortcutHelp"), helpShortcut], - ["history", t("settings.shortcutHistory"), historyShortcut], - ["api", t("settings.shortcutApi"), apiShortcut], - ["theme", t("settings.shortcutTheme"), themeShortcut], - ["focusTimer", t("settings.shortcutFocusTimer"), focusTimerShortcut], - ] as [string, string, string][]) as [target, label, value]} -
- {label} - -
- {#if recording && recordingTarget === target} - - {t("settings.pressKeysEsc")} - - {:else if value} - {#each shortcutTokens(value) as token} - - {token} - - {/each} - {:else} - {t("settings.notSet")} - {/if} -
- - {#if shortcutError && recordingTarget === target} - - {shortcutError} - - {/if} - -
- {#if value && !(recording && recordingTarget === target)} - - {/if} - {#if recording && recordingTarget === target} - - {:else} - - {/if} -
-
- {/each} - -
-
-
- - -
-
- - {t("shortcutsTab.commandPalette")} - -
- - - -
-
- {t("shortcutsTab.cmdKTitle")} - - {t("shortcutsTab.cmdKDesc")} - -
- - {isMac ? "⌘" : "Ctrl"}K - -
-
-
-
- - -
-
- - {t("shortcutsTab.inAppShortcuts")} - -
- - - - - {#each ([ - ["?", t("shortcuts.showOverlay")], - [isMac ? "⌘K" : "Ctrl+K", t("shortcutsTab.cmdKTitle")], - ["Esc", t("shortcuts.closeOverlay")], - [isMac ? "⌘↵" : "Ctrl+↵", t("shortcuts.submitLabel")], - ] as [string, string][]) as [keys, label]} -
- {label} - - {keys} - -
- {/each} - -
-
-
diff --git a/src/lib/ThemeToggle.svelte b/src/lib/ThemeToggle.svelte index 2d4643e52..3ee42c39e 100644 --- a/src/lib/ThemeToggle.svelte +++ b/src/lib/ThemeToggle.svelte @@ -8,56 +8,54 @@ the Free Software Foundation, version 3 only. --> Shows: ◐ system, ☀ light, ● dark. Teleports tooltip to body to escape overflow clipping. --> - - - -
- - - -
- - {t("ttsTab.statusSection")} - - - - - - -
- {#if enginePhase === "ready"} - - - {t("ttsTab.statusReady")} - - {:else if enginePhase === "loading"} - - - {t("ttsTab.statusLoading")} - - {:else if enginePhase === "error"} - - - {t("ttsTab.statusError")} - - {:else if enginePhase === "unloaded"} - - - {t("ttsTab.statusUnloaded")} - - {:else} - - - {t("ttsTab.statusIdle")} - - {/if} - - -
- - -
-
- - - {#if enginePhase === "loading"} -
-
- - {loadLabel || "Connecting…"} - - {#if loadTotal > 0} - - {loadStep}/{loadTotal} - - {/if} -
-
-
-
-
- {/if} - - - {#if enginePhase === "error" && errorMsg} -
-

- {t("ttsTab.errorTitle")} -

-

- {errorMsg} -

-
- {/if} - - -

- {t("ttsTab.requirements")} · {t("ttsTab.requirementsDesc")} -

- -
-
-
- - - {#if activeBackend === "kitten"} -
- - {t("ttsTab.kittenConfigSection")} - - - - - - -
- - {t("ttsTab.kittenVoiceLabel")} - -
- {#each kittenVoices as v} - - {/each} -
-
- - -

- {t("ttsTab.kittenModelInfo")} -

- -
-
-
- {/if} - - - {#if activeBackend === "neutts"} -
- - {t("ttsTab.neuttsConfigSection")} - - - - - - -
-
- - {t("ttsTab.neuttsModelLabel")} - - - {t("ttsTab.neuttsModelDesc")} - -
- - - - {#if selectedModel()} - {@const m = selectedModel()!} -
- ✓ {m.pros} - ✗ {m.cons} -
- {/if} -
- - -
-
- - {t("ttsTab.neuttsVoiceSection")} - - - {t("ttsTab.neuttsVoiceDesc")} - -
- - -
- - {t("ttsTab.neuttsPresetLabel")} - -
- {#each PRESET_VOICES as pv} - - {/each} - - - -
-
- - - {#if neuttsConfig.voice_preset === ""} -
- - -
- - {t("ttsTab.neuttsRefWavLabel")} - -
- - {neuttsConfig.ref_wav_path || t("ttsTab.neuttsRefWavNone")} - - -
-
- - -
- - {t("ttsTab.neuttsRefTextLabel")} - - -
- -
- {/if} -
- - -
- -
- -
-
-
- {/if} - - -
- - {t("ttsTab.testSection")} - -

- {t("ttsTab.testDesc")} -

- -
- - -
- - {t("ttsTab.apiSection")} - -

- {t("ttsTab.apiDesc")} -

-
- {#each [ - ["WebSocket", `{"command":"say","text":"Eyes closed. Relax."}`], - ["HTTP (curl)", `curl -X POST http://localhost:/say \\ - -H 'Content-Type: application/json' \\ - -d '{"text":"Eyes closed. Relax."}'`], - ["websocat (CLI)", `echo '{"command":"say","text":"Eyes closed."}' | websocat ws://localhost:`], - ] as [label, code]} -
- - {label} - -
{code}
-
- {/each} -
-
- - -
- - {t("ttsTab.startupSection")} - - - - - - - -
- - -
- - {t("ttsTab.loggingSection")} - - - - - - - -
- - diff --git a/src/lib/UmapScene.svelte b/src/lib/UmapScene.svelte deleted file mode 100644 index a206f2e86..000000000 --- a/src/lib/UmapScene.svelte +++ /dev/null @@ -1,401 +0,0 @@ - - - - - - - diff --git a/src/lib/UmapTab.svelte b/src/lib/UmapTab.svelte deleted file mode 100644 index a61c44d50..000000000 --- a/src/lib/UmapTab.svelte +++ /dev/null @@ -1,419 +0,0 @@ - - - - - -{#if !loaded} -
- {t("common.loading")} -
-{:else} - - -
-
- - {t("umapSettings.repulsion")} - - {#if saving} - {t("common.saving")} - {/if} - {#if dirty} - - {/if} -
- - - - - -
-
- {t("umapSettings.repulsionStrength")} - {cfg.repulsion_strength.toFixed(1)} -
-

- {t("umapSettings.repulsionDesc")} -

-
- {#each REPULSION_PRESETS as [label, val]} - - {/each} -
- -
- - -
-
- {t("umapSettings.negSampleRate")} - {cfg.neg_sample_rate} -
-

- {t("umapSettings.negSampleRateDesc")} -

-
- {#each NEG_SAMPLE_PRESETS as [label, val]} - - {/each} -
-
-
-
-
- - -
- - {t("umapSettings.graphOptimisation")} - - - - - - -
-
- {t("umapSettings.nNeighbors")} - {cfg.n_neighbors} -
-

- {t("umapSettings.nNeighborsDesc")} -

-
- {#each NEIGHBOR_PRESETS as [label, val]} - - {/each} -
-
- - -
-
- {t("umapSettings.nEpochs")} - {cfg.n_epochs} -
-

- {t("umapSettings.nEpochsDesc")} -

-
- {#each EPOCH_PRESETS as [label, val]} - - {/each} -
-
-
-
-
- - -
- - {t("umapSettings.safetyPerformance")} - - - - - - -
-
- {t("umapSettings.timeout")} - {cfg.timeout_secs}s -
-

- {t("umapSettings.timeoutDesc")} -

-
- {#each TIMEOUT_PRESETS as [label, val]} - - {/each} -
-
- - -
-
- {t("umapSettings.cooldownMs")} - - {cfg.cooldown_ms === 0 ? "0 ms" : cfg.cooldown_ms < 1000 ? `${cfg.cooldown_ms} ms` : `${(cfg.cooldown_ms / 1000).toFixed(1)}s`} - -
-

- {t("umapSettings.cooldownMsDesc")} -

- -
- 0s5s10s -
-
- - -
- - {t("umapSettings.pipeline")} - - - repulsion {cfg.repulsion_strength.toFixed(1)} - - - neg×{cfg.neg_sample_rate} - - - k={cfg.n_neighbors} - - - {cfg.n_epochs} epochs - - - {cfg.timeout_secs}s timeout - - - {cfg.cooldown_ms}ms cooldown - - fast-umap 1.2.2 · wgpu -
-
-
-
- - -
- - {#if dirty} - - {/if} -
- -{/if} - - diff --git a/src/lib/UmapViewer3D.svelte b/src/lib/UmapViewer3D.svelte deleted file mode 100644 index f8d0cda81..000000000 --- a/src/lib/UmapViewer3D.svelte +++ /dev/null @@ -1,1657 +0,0 @@ - - - - - - -
- - - {#if traceActive && traceTimeRange && traceTimeTicks.length > 0} - {@const prog = traceTotal > 0 ? traceProgress / traceTotal : 0} -
-
- -
- -
-
- -
- {#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} - - {/each} -
-
-
- {/if} - -
- - - {#if sidebarOpen && uniqueLabels.length > 0} - {@const groups = [ - { key: "common", label: t("umap.common"), items: uniqueLabels.filter(l => l.inA && l.inB) }, - { key: "a-only", label: t("umap.sessionA"), items: uniqueLabels.filter(l => l.inA && !l.inB) }, - { key: "b-only", label: t("umap.sessionB"), items: uniqueLabels.filter(l => !l.inA && l.inB) }, - ].filter(g => g.items.length > 0)} -
- -
- - {t("umap.labels")} - - - - { sidebarOpen = false; selectedLabel = null; applyHighlight(); }}>✕ -
- -
- - - {t("umap.sessionA")} - - - - {t("umap.sessionB")} - -
- - -
- {#each groups as group} - -
- {group.label} -
-
- - {#each group.items as entry} - {@const isSelected = selectedLabel === entry.label} - {@const isProximate = proximateLabels.includes(entry.label)} - {@const hex = labelHex(entry.hue)} - - -
{ selectedLabel = selectedLabel === entry.label ? null : entry.label; applyHighlight(); }}> - -
- {#if entry.inA && entry.inB} - - - - {:else if entry.inA} - - {:else} - - {/if} - {entry.label} -
- {#if entry.inA} - A - {/if} - {#if entry.inB} - B - {/if} -
-
- -
- {#each entry.timestamps.slice(0, 6) as ts} - - {fmtUtcTime(ts.utc)} - - {/each} - {#if entry.timestamps.length > 6} - - +{entry.timestamps.length - 6} - - {/if} -
-
- {/each} - {/each} -
- - -
-
- - {t("umap.proximity")} - - - {proximityDist.toFixed(1)} - -
- -
- {#if selectedLabel && !animating} - {#if proximateLabels.length > 0} - - {proximateLabels.length} {t("umap.nearbyLabels")} - - {:else} - {t("umap.noNearbyLabels")} - {/if} - {:else if animating} - {t("umap.computing")} - {:else} - {t("umap.selectLabelHint")} - {/if} -
-
-
- {/if} - - -
- - - {#if cmdHeld} -
- ⌘ Pan -
- {/if} - - {#if error} -
- {t("umap.error")} -
{error}
-
- {:else if !loaded} -
-
- - {t("umap.loading")} -
-
- {/if} - - - {#if computing} -
-
-
-
- -
- {t("umap.computing")} - {#if progress && progress.total_epochs > 0} - {@const pct = Math.round(progress.epoch / progress.total_epochs * 100)} - -
-
-
-
- {pct}% -
- - - epoch {progress.epoch}/{progress.total_epochs} · {progress.epoch_ms.toFixed(0)}ms/ep - - {:else} - {t("umap.placeholder")} - {/if} -
-
- {/if} - - - {#if activeLabels.length > 0} -
- {#each activeLabels as label} - {@const entry = linkGroups.get(label)} - {@const col = entry ? LINK_PALETTE[entry.colorIdx % LINK_PALETTE.length] : 0xffffff} -
- - {label} - - - removeLinkGroup(label)}>✕ -
- {/each} - {#if activeLabels.length > 1} - - -
clearAllLinks()}> - {t("umap.clearAll")} -
- {/if} -
- {/if} - - -
- - - {#if timeGradient && gradientRange} - {@const span = gradientRange.maxUtc - gradientRange.minUtc} -
- - Session {timeGradient} · time → - -
- - {fmtGradientTs(gradientRange.minUtc, span)} - -
-
- - {fmtGradientTs(gradientRange.maxUtc, span)} - -
-
- {/if} - - - {#if !timeGradient && colorByDate && dateLegend.length > 0} -
- {#each dateLegend as dl} -
- - {dl.date} -
- {/each} -
- {/if} - - - {#if loaded && !error && uniqueLabels.length > 0} - {@const colAcss = `#${UMAP_COLOR_A.toString(16).padStart(6,"0")}`} - {@const colBcss = `#${UMAP_COLOR_B.toString(16).padStart(6,"0")}`} -
- - -
- - {t("umap.sessionA")} — {t("umap.shapeSphere")} -
-
- - {t("umap.sessionB")} — {t("umap.shapeDiamond")} -
- - - {#if selectedLabel} -
-
- - {selectedLabel} - {t("umap.legendSelected")} -
- {#if proximateLabels.length > 0} -
- - {proximateLabels.length} {t("umap.legendNearby")} -
- {:else} -
- - {t("umap.noNearbyLabels")} -
- {/if} -
- - {t("umap.legendDimmed")} -
- {/if} -
- {/if} -
- - - {#if loaded && !error && curPoints.length >= 2} -
- - - -
-
{ timeGradient = timeGradient === 'A' ? null : 'A'; }}> - 🌈 A -
-
-
{ timeGradient = timeGradient === 'B' ? null : 'B'; }}> - 🌈 B -
-
- - - - -
- - {traceActive ? t("umap.traceStop") : t("umap.trace")} - - {#if traceActive && traceTotal > 0} - {traceProgress}/{traceTotal} - {/if} -
- - - {#if uniqueLabels.length > 0} - - -
{ sidebarOpen = !sidebarOpen; if (!sidebarOpen) { selectedLabel = null; applyHighlight(); } }}> - - 🏷 {uniqueLabels.length} - -
- {/if} -
- {/if} - - - {#if loaded && !error} -
- {t("umap.nodeSize")} - - {pointScale.toFixed(1)}× -
- {/if} - - - {#if tooltip} -
- {tooltip.text} -
- {/if} -
-
-
- - diff --git a/src/lib/UpdatesTab.svelte b/src/lib/UpdatesTab.svelte deleted file mode 100644 index de20acd96..000000000 --- a/src/lib/UpdatesTab.svelte +++ /dev/null @@ -1,488 +0,0 @@ - - - - - -
- - -
-
- -
-
- {t("updates.title")} - - {t("updates.currentVersion", { version: appVersion })} - -
- - {#if phase === "ready"} - - ✅ {t("updates.readyToRestart")} - - {:else if phase === "downloading"} - {progress}% - {:else if phase === "checking"} - - - - {/if} -
- - - - - -
- - {#if phase === "ready"} - -
-
- ✅ -
-
- - {t("updates.installed", { version: available?.version ?? "" })} - - - {t("updates.restartingIn", { secs: countdown })} - -
-
- - -
-
- - -
-
-
- - {:else if phase === "downloading"} - -
-
- - {t("updates.downloading", { version: available?.version ?? "" })} - - - {progress}% - -
-
-
-
- {#if available?.body} -

{available.body}

- {/if} -
- - {:else} - -
- {#if phase === "error" && available} - -
- ⚠ -
-
- - v{available.version} {t("updates.available")} - - - {t("updates.downloadFailed")} - -
- {:else} -
- - {phase === "checking" ? t("updates.checking") : t("updates.upToDate")} - - - {t("updates.lastChecked")}: {fmtLastChecked()} - -
- {/if} - - - -
- - - {#if phase === "error" && error} -
- {error} -
- {/if} - {/if} - -
-
-
- - - - -
-
-
- - {t("updates.checkInterval")} - - - {t("updates.checkIntervalDesc")} - -
- {#if intervalSaving} - - - - {/if} -
- -
- {#each INTERVAL_OPTIONS as [secs, labelKey]} - - {/each} -
- - {#if checkIntervalSecs === 0} -

- {t("updates.intervalOffWarning")} -

- {/if} -
-
-
- - - - - - - {#if autostartError} -
- {autostartError} -
- {/if} -
-
- - -
- - {t("updates.footer")} - -
- -
diff --git a/src/lib/WhatsNew.svelte b/src/lib/WhatsNew.svelte index bbd7271e8..f4bc928a4 100644 --- a/src/lib/WhatsNew.svelte +++ b/src/lib/WhatsNew.svelte @@ -5,203 +5,33 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> - -{#if latest} - - - - -
- - -
-
- -
-
- - {t("whatsNew.title")} - - - {t("whatsNew.version", { version: latest.version })} -  ·  - {latest.date} - -
-
- -
- - -
- - {#each latest.sections as section} -
- - {#if section.heading} -

- {section.heading} -

- {/if} - -
    - {#each section.items as item} -
  • - - {item} -
  • - {/each} -
- -
- {/each} - -
- - -
- -
- -
-
-{/if} diff --git a/src/lib/charts/BandChart.svelte b/src/lib/charts/BandChart.svelte new file mode 100644 index 000000000..788a4ae22 --- /dev/null +++ b/src/lib/charts/BandChart.svelte @@ -0,0 +1,302 @@ + + + + + + + +
+ +
diff --git a/src/lib/EegChart.svelte b/src/lib/charts/EegChart.svelte similarity index 83% rename from src/lib/EegChart.svelte rename to src/lib/charts/EegChart.svelte index 0fd93ed7c..bb54bd4ca 100644 --- a/src/lib/EegChart.svelte +++ b/src/lib/charts/EegChart.svelte @@ -5,22 +5,22 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> + +
+ + + +
+ + + + {#each genericTabs as tab} + + {/each} +
+ + + {#if activeTab === "muse"} +
+ {#each museChannels as name, idx} + {@const q = effectiveQuality ? effectiveQuality[idx] : 0} + {@const label = effectiveLabels[idx]} + {@const el = visible.find(e => e.name === name)} + + {/each} +
+ {:else} + + {@const stripNames = channelNames.length > 0 ? channelNames : visible.map(e => e.name)} + {@const stripCount = stripNames.length} +
+ Signal + {#each stripNames as chName, idx} + {@const label = (qualityLabels ?? [])[idx] ?? "no_signal"} + {@const q = labelToNum(label)} +
+ + {chName} +
+ {/each} +
+ {/if} + + + + +
+ {#if Head3D} + + + + {:else} +
+ Loading 3D view… +
+ {/if} + + +
+ {#each Object.entries(regionColors) as [region, color]} + {#if region !== "reference"} + + + {regionLabels[region as BrainRegion]} + + {/if} + {/each} +
+ + +
+ Drag to rotate · Click electrode +
+
+ + + {#if selectedElectrode} + {@const el = selectedElectrode} +
+
+ + {el.name} + {#if el.muse} + Muse + {/if} + {regionLabels[el.region]} + {#if el.muse && effectiveQuality} + {@const chIdx = museChannels.indexOf(el.name)} + {#if chIdx >= 0} + {@const q = effectiveQuality[chIdx]} + + Signal: {labelToText(effectiveLabels[chIdx])} + + {/if} + {/if} + +
+
+
+ Position +

{el.lobe} — {el.function}

+
+
+ Signals +

{el.signals}

+
+ {#if el.museRole} +
+ Muse Role +

{el.museRole}

+
+ {/if} +
+
+ {/if} +
diff --git a/src/lib/charts/ElectrodeHead3D.svelte b/src/lib/charts/ElectrodeHead3D.svelte new file mode 100644 index 000000000..413da9623 --- /dev/null +++ b/src/lib/charts/ElectrodeHead3D.svelte @@ -0,0 +1,477 @@ + + + + + ref?.lookAt(0, 0, 0)} +> + + + + + + + + + + { group = ref; }}> + {#if gltfScene} + + {/if} + + + + {#if modelLoaded} + + {/if} + + + + {#if selRing} + { + if (selRing) { + const target = selRing.position.clone().add(selRing.normal); + ref.lookAt(target.x, target.y, target.z); + } + }} + /> + {/if} + + diff --git a/src/lib/charts/ElectrodePlacement.svelte b/src/lib/charts/ElectrodePlacement.svelte new file mode 100644 index 000000000..e347b430f --- /dev/null +++ b/src/lib/charts/ElectrodePlacement.svelte @@ -0,0 +1,234 @@ + + + + + + diff --git a/src/lib/charts/FnirsChart.svelte b/src/lib/charts/FnirsChart.svelte new file mode 100644 index 000000000..6686cd877 --- /dev/null +++ b/src/lib/charts/FnirsChart.svelte @@ -0,0 +1,127 @@ + + + +
+
+ fNIRS Trends + WL30 {summary.wl30.toFixed(1)} · Oxy30 {summary.oxy30.toFixed(1)} · Fatigue {summary.fatigue.toFixed(1)} +
+ +
diff --git a/src/lib/charts/GpuBar.svelte b/src/lib/charts/GpuBar.svelte new file mode 100644 index 000000000..dd0843989 --- /dev/null +++ b/src/lib/charts/GpuBar.svelte @@ -0,0 +1,128 @@ + + + + + +{#if gpuStats !== null} +
+ + + GPU + + + + {#if spark} + + + + + {/if} + + +
+
+
+
+ + + + {(gpuStats.overall * 100).toFixed(0)}% + +
+{/if} diff --git a/src/lib/charts/GpuChart.svelte b/src/lib/charts/GpuChart.svelte new file mode 100644 index 000000000..3826bc1ab --- /dev/null +++ b/src/lib/charts/GpuChart.svelte @@ -0,0 +1,272 @@ + + + + + +{#if visible} +
+ +
+ {t("gpu.title")} + + + {t("gpu.render")} {(gpuStats!.render * 100).toFixed(0)}% · {t("gpu.tiler")} {(gpuStats!.tiler * 100).toFixed(0)}% + + + {(gpuStats!.overall * 100).toFixed(0)}% + + + +
+ + {#if !collapsed} +
+ +
+ {/if} + +
+{/if} + + diff --git a/src/lib/charts/Hypnogram.svelte b/src/lib/charts/Hypnogram.svelte new file mode 100644 index 000000000..1efa36237 --- /dev/null +++ b/src/lib/charts/Hypnogram.svelte @@ -0,0 +1,127 @@ + + + + + +
+ + + + +
+ {#each STAGES as s} + {@const count = s.stage === 0 ? summary.wake_epochs + : s.stage === 5 ? summary.rem_epochs + : s.stage === 1 ? summary.n1_epochs + : s.stage === 2 ? summary.n2_epochs + : summary.n3_epochs} +
+ + {t(s.key)} + + {stagePct(count)}% · {stageDur(count)} + +
+ {/each} +
+
diff --git a/src/lib/ImuChart.svelte b/src/lib/charts/ImuChart.svelte similarity index 74% rename from src/lib/ImuChart.svelte rename to src/lib/charts/ImuChart.svelte index 93c7c0136..9039c367e 100644 --- a/src/lib/ImuChart.svelte +++ b/src/lib/charts/ImuChart.svelte @@ -5,18 +5,18 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. -->
- {t("dashboard.accel")} + {t("dashboard.accel")} {#each AXIS_LABELS as axis, i} - + {axis}{latestAccel[i] >= 0 ? "+" : ""}{latestAccel[i].toFixed(3)} {/each}
- {t("dashboard.gyro")} + {t("dashboard.gyro")} {#each AXIS_LABELS as axis, i} - + {axis}{latestGyro[i] >= 0 ? "+" : ""}{latestGyro[i].toFixed(1)} {/each}
- +
diff --git a/src/lib/charts/InteractiveGraph3D.svelte b/src/lib/charts/InteractiveGraph3D.svelte new file mode 100644 index 000000000..613c3a2a7 --- /dev/null +++ b/src/lib/charts/InteractiveGraph3D.svelte @@ -0,0 +1,1176 @@ + + + + + + +
{ tooltip = null; hoveredNodeId = null; }} + role="img" aria-label="Interactive search 3D graph"> + + + {#if tooltip} +
+ {#each tooltip.lines as line, li} +
+ {line} +
+ {/each} +
+ {/if} + + + {#if loaded && nodeEntries.length > 1} + {@const mmSize = 80} + {@const mmNodes = nodeEntries.map(ne => ({ + x: ne.mesh.position.x, + z: ne.mesh.position.z, + kind: ne.node.kind, + selected: ne.node.id === selectedNodeId, + }))} + {@const xMin = Math.min(...mmNodes.map(n => n.x)) - 2} + {@const xMax = Math.max(...mmNodes.map(n => n.x)) + 2} + {@const zMin = Math.min(...mmNodes.map(n => n.z)) - 2} + {@const zMax = Math.max(...mmNodes.map(n => n.z)) + 2} + {@const xRange = xMax - xMin || 1} + {@const zRange = zMax - zMin || 1} +
+ + {#each mmNodes as mn} + {@const mx = ((mn.x - xMin) / xRange) * (mmSize - 8) + 4} + {@const mz = ((mn.z - zMin) / zRange) * (mmSize - 8) + 4} + + {/each} + +
+ {/if} + + + + + + {#if selectedNodeId !== null} +
+ click node or empty space to deselect · double-click to zoom · ⌂ to reset +
+ {/if} + + + {#if !loaded} +
+
+
+ {/if} +
+ + +
+ + +
+ {#each LEGEND_BASE as l} +
+ + {l.label} +
+ {/each} +
+ + {foundLabelLegend} +
+ {#if nodes.some(n => n.kind === "screenshot")} +
+ + Screenshot +
+ {/if} + · + {#each EDGE_LEGEND as l} +
+ + {l.label} +
+ {/each} + {#if nodes.some(n => n.kind === "screenshot")} +
+ + Screenshot link +
+ {/if} + + hover · click to highlight · click again to clear + +
+ + + {#if eegDots.length > 0} +
+ + EEG node time scale + + + + + {#each Array.from({ length:12 }, (_,i) => i/11) as t} + + {/each} + + + + {#each eegDots as dot} + {@const x = dot.t * 500} + + + {/each} + {#each eegTicks as tick} + {@const x = tick.t * 500} + + + {tick.label} + + {/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/PpgChart.svelte b/src/lib/charts/PpgChart.svelte similarity index 78% rename from src/lib/PpgChart.svelte rename to src/lib/charts/PpgChart.svelte index ca7df3511..fe1e5321e 100644 --- a/src/lib/PpgChart.svelte +++ b/src/lib/charts/PpgChart.svelte @@ -5,29 +5,29 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. -->
@@ -246,21 +233,21 @@ the Free Software Foundation, version 3 only. --> {#each CH as label, i}
- {label} - + {label} + {latestValues[i] > 0 ? latestValues[i].toFixed(0) : "—"}
{/each}
- +
@@ -271,9 +258,9 @@ the Free Software Foundation, version 3 only. -->
{ ppgTooltip = null; }} > {ppgTooltip.text} diff --git a/src/lib/charts/chart-theme.ts b/src/lib/charts/chart-theme.ts new file mode 100644 index 000000000..484627e0d --- /dev/null +++ b/src/lib/charts/chart-theme.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +/** + * Shared chart theme helper — reads CSS custom properties defined in app.css + * and provides them as typed values for canvas-based chart rendering. + * + * Usage: + * const theme = readChartTheme(canvas); + * ctx.fillStyle = theme.bg; + */ + +export interface ChartTheme { + bg: string; + bgStrip: string; + grid: string; + baseline: string; + label: string; +} + +/** + * Read the chart CSS custom properties from the computed style of an element. + * Falls back to sensible dark-mode defaults if the properties aren't set. + */ +export function readChartTheme(el: HTMLElement): ChartTheme { + const cs = getComputedStyle(el); + return { + bg: cs.getPropertyValue("--chart-bg").trim() || "#0d0d1a", + bgStrip: cs.getPropertyValue("--chart-bg-strip").trim() || "#111120", + grid: cs.getPropertyValue("--chart-grid").trim() || "rgba(255,255,255,0.07)", + baseline: cs.getPropertyValue("--chart-baseline").trim() || "rgba(255,255,255,0.12)", + label: cs.getPropertyValue("--chart-label").trim() || "rgba(255,255,255,0.4)", + }; +} diff --git a/src/lib/charts/graph3d-logic.ts b/src/lib/charts/graph3d-logic.ts new file mode 100644 index 000000000..a62eb27cb --- /dev/null +++ b/src/lib/charts/graph3d-logic.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * InteractiveGraph3D pure logic — extracted from InteractiveGraph3D.svelte. + * + * 3D vector math, Fibonacci sphere layout, and Turbo colormap. + */ + +export type Vec3 = [number, number, number]; + +const GOLDEN = Math.PI * (3 - Math.sqrt(5)); // golden angle in radians + +// ── Vector math ─────────────────────────────────────────────────────────────── + +export function add3(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +export function scale3(v: Vec3, s: number): Vec3 { + return [v[0] * s, v[1] * s, v[2] * s]; +} + +export function normalize3(v: Vec3): Vec3 { + const len = Math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2) || 1; + return [v[0] / len, v[1] / len, v[2] / len]; +} + +export function length3(v: Vec3): number { + return Math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2); +} + +// ── Fibonacci sphere ────────────────────────────────────────────────────────── + +/** + * Compute a point on the Fibonacci sphere for index `i` out of `n` total points. + * Produces an approximately uniform distribution on the unit sphere. + */ +export function fibSphere(i: number, n: number): Vec3 { + const y = 1 - (i / Math.max(n - 1, 1)) * 2; + const r = Math.sqrt(Math.max(0, 1 - y * y)); + const theta = GOLDEN * i; + return [Math.cos(theta) * r, y, Math.sin(theta) * r]; +} + +// ── Turbo colormap (simplified) ─────────────────────────────────────────────── + +/** + * Attempt a piecewise approximation of the Turbo colormap for t in [0, 1]. + * Returns [r, g, b] in [0, 1]. + */ +export function turbo(t: number): Vec3 { + const tc = Math.max(0, Math.min(1, t)); + const r = Math.max(0, Math.min(1, 1.5 - Math.abs(tc - 0.75) * 4)); + const g = Math.max(0, Math.min(1, 1.5 - Math.abs(tc - 0.5) * 4)); + const b = Math.max(0, Math.min(1, 1.5 - Math.abs(tc - 0.25) * 4)); + return [r, g, b]; +} + +/** Convert a Turbo colormap value to a CSS hex string. */ +export function turboCss(t: number): string { + const [r, g, b] = turbo(t); + const hex = (v: number) => + Math.round(v * 255) + .toString(16) + .padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; +} + +/** Convert a Turbo colormap value to a packed 0xRRGGBB integer. */ +export function turboHex(t: number): number { + const [r, g, b] = turbo(t); + return (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255); +} diff --git a/src/lib/charts/index.ts b/src/lib/charts/index.ts new file mode 100644 index 000000000..f7932fe71 --- /dev/null +++ b/src/lib/charts/index.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. + +export { type ChartTheme, readChartTheme } from "./chart-theme"; +// Re-export chart infrastructure +export { type AnimatedCanvasOpts, animatedCanvas } from "./use-canvas"; diff --git a/src/lib/charts/use-canvas.ts b/src/lib/charts/use-canvas.ts new file mode 100644 index 000000000..d92d00e99 --- /dev/null +++ b/src/lib/charts/use-canvas.ts @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// Shared canvas lifecycle helper — DRY replacement for the ResizeObserver + +// requestAnimationFrame + DPR boilerplate duplicated across EegChart, +// BandChart, PpgChart, GpuChart, ImuChart, etc. +// +// Usage (Svelte action): +// +// +// +// The action: +// 1. Scales the canvas for the current device-pixel-ratio. +// 2. Observes `container ?? canvas.parentElement` for size changes. +// 3. Runs a `requestAnimationFrame` loop calling `draw(ctx, cssW, cssH)`. +// 4. Cleans up on destroy (cancels RAF, disconnects observer). + +import { getDpr } from "$lib/format"; + +export interface AnimatedCanvasOpts { + /** Called every animation frame with the 2-D context and logical size. */ + draw: (ctx: CanvasRenderingContext2D, width: number, height: number) => void; + + /** Fixed CSS height of the canvas (logical px). */ + heightPx: number; + + /** + * Fixed CSS width (logical px). When set, the canvas is sized to this + * constant instead of tracking the container's width. + */ + widthPx?: number; + + /** + * Element to observe for width changes. Defaults to `canvas.parentElement`. + * Ignored when `widthPx` is set (no need to track container width). + */ + container?: HTMLElement; + + /** + * If true the RAF loop is NOT started automatically — call the returned + * `start()` function manually (useful when the chart is initially hidden). + */ + manual?: boolean; +} + +/** + * Svelte action that manages the RAF + ResizeObserver lifecycle for a ``. + */ +export function animatedCanvas( + canvas: HTMLCanvasElement, + opts: AnimatedCanvasOpts, +): { destroy: () => void; update: (o: AnimatedCanvasOpts) => void } { + let { draw, heightPx, widthPx, container, manual } = opts; + let raf: number | undefined; + let ro: ResizeObserver | undefined; + let running = false; + + function resize() { + const dpr = getDpr(); + if (widthPx != null) { + canvas.width = Math.round(widthPx * dpr); + canvas.height = Math.round(heightPx * dpr); + } else { + const observed = container ?? canvas.parentElement ?? canvas; + const w = observed.clientWidth || canvas.clientWidth; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(heightPx * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${heightPx}px`; + } + } + + function tick() { + raf = requestAnimationFrame(tick); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = getDpr(); + const w = canvas.width / dpr; + const h = canvas.height / dpr; + try { + draw(ctx, w, h); + } catch (_err) {} + } + + function start() { + if (running) return; + running = true; + raf = requestAnimationFrame(tick); + } + + function stop() { + running = false; + if (raf !== undefined) { + cancelAnimationFrame(raf); + raf = undefined; + } + } + + // Initial setup + if (widthPx == null) { + const observed = container ?? canvas.parentElement ?? canvas; + ro = new ResizeObserver(resize); + ro.observe(observed); + } + resize(); + + if (!manual) start(); + + return { + update(o: AnimatedCanvasOpts) { + draw = o.draw; + heightPx = o.heightPx; + widthPx = o.widthPx; + if (o.container !== container) { + container = o.container; + ro?.disconnect(); + if (widthPx == null) { + const newObs = container ?? canvas.parentElement ?? canvas; + ro = new ResizeObserver(resize); + ro.observe(newObs); + } + } + resize(); + }, + destroy() { + stop(); + ro?.disconnect(); + }, + }; +} diff --git a/src/lib/chat/ChatContextBreakdown.svelte b/src/lib/chat/ChatContextBreakdown.svelte new file mode 100644 index 000000000..c3da36dd4 --- /dev/null +++ b/src/lib/chat/ChatContextBreakdown.svelte @@ -0,0 +1,128 @@ + + + + + + + +
+
e.stopPropagation()} + > + +
+ {t("chat.ctx.title")} + {#if isEstimate} + {t("chat.ctx.estimated")} + {/if} +
+ + +
+ {#each sortedSegments as seg, i (seg.key)} +
+ {/each} +
+ + +
+ {#each sortedSegments as seg (seg.key)} + {@const share = pctOfUsed(seg.tokens)} +
+ + {t(seg.labelKey)} + {fmtNum(seg.tokens)} + {fmtPct1(share)}% +
+ {/each} + + +
+ + {t("chat.ctx.free")} + {fmtNum(freeTokens)} + {fmtPct1(pctOfCtx(freeTokens))}% +
+
+ + +
+ {t("chat.ctx.total")} + + {fmtNum(totalUsed)} / {fmtNum(nCtx)} + ({fmtPct1(pctOfCtx(totalUsed))}%) + +
+ + + {#if onViewFull} + + {/if} +
+
diff --git a/src/lib/chat/ChatContextViewer.svelte b/src/lib/chat/ChatContextViewer.svelte new file mode 100644 index 000000000..c8ae4a200 --- /dev/null +++ b/src/lib/chat/ChatContextViewer.svelte @@ -0,0 +1,227 @@ + + + + + + + + +
+ +
+ + + + +
e.stopPropagation()} + > + +
+
+

{t("chat.ctx.viewerTitle")}

+ + ~{totalTokens.toLocaleString()} {t("chat.ctx.tokens")} / {nCtx.toLocaleString()} {t("chat.ctx.nCtx")} + +
+
+ + + + +
+
+ + +
+ {#each apiMessages as msg, i (i)} + {@const tokens = estimateTokens(msg.content) + 10} +
+ +
+
+ + {msg.role} + + {#if msg.label} + — {msg.label} + {/if} +
+ ~{tokens.toLocaleString()} {t("chat.ctx.tokens")} +
+ +
{msg.content}
+
+ {/each} + + {#if apiMessages.length === 0} +
+ {t("chat.ctx.empty")} +
+ {/if} +
+ + +
+ {apiMessages.length} {t("chat.ctx.messagesCount")} + + ~{totalTokens.toLocaleString()} / {nCtx.toLocaleString()} {t("chat.ctx.tokens")} + ({nCtx > 0 ? ((totalTokens / nCtx) * 100).toFixed(1) : "0.0"}%) + +
+
+
diff --git a/src/lib/chat/ChatHeader.svelte b/src/lib/chat/ChatHeader.svelte new file mode 100644 index 000000000..ded04473a --- /dev/null +++ b/src/lib/chat/ChatHeader.svelte @@ -0,0 +1,259 @@ + + + + + + diff --git a/src/lib/chat/ChatInputBar.svelte b/src/lib/chat/ChatInputBar.svelte new file mode 100644 index 000000000..f619d7a0e --- /dev/null +++ b/src/lib/chat/ChatInputBar.svelte @@ -0,0 +1,333 @@ + + + + + +
+ + + {#if eegBlocked} +
+ + + + + + + {t("chat.eeg.noSignalBlockedHint")} + +
+ {/if} + + + {#if attachments.length > 0 && !supportsVision} +
+ + + + + + {t("chat.noVision")} + +
+ {/if} + + + {#if attachments.length > 0} +
+ {#each attachments as att, i} +
+ {att.name} + +
+ {/each} +
+ {/if} + + + + + + {#if nCtx > 0 && liveUsedTokens > 0} + {@const warnPct = Math.round((liveUsedTokens / nCtx) * 100)} + {#if warnPct >= 85} +
+ + + + + + {t("chat.ctxWarning", { pct: warnPct })} + +
+ {/if} + {/if} + + +
+ + + + + + {t("chat.hint.llmWarning")} + +
+ +
+ + + + + + + + + + {#if generating} + + {:else} + + {/if} +
+ +

+ {#if eegBlocked} + {t("chat.hint.noEeg")} + {:else if status === "running"} + {t("chat.hint.running")} + {:else if status === "loading"} + {t("chat.hint.loading")} + {:else} + {t("chat.hint.stopped")} + {/if} +

+
+ + + diff --git a/src/lib/chat/ChatMessageList.svelte b/src/lib/chat/ChatMessageList.svelte new file mode 100644 index 000000000..790610ec5 --- /dev/null +++ b/src/lib/chat/ChatMessageList.svelte @@ -0,0 +1,426 @@ + + + + + +
+
+ + + {#if messages.length === 0} +
+
+ + + + +
+ {#if status === "stopped"} +
+

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

+

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

+
+ + +
+
+ {:else if status === "loading"} +
+

{t("chat.status.loading")}

+ + +
+ {#each loadingSteps as step, i} +
+ {#if loadingActiveIdx > i} + + + + {:else if loadingActiveIdx === i} + + + + + {:else} +
+
+
+ {/if} + {step.label} +
+ {/each} +
+ + +
+
+
+
+ {:else} +

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

+ {/if} +
+ + {:else} + {#each messages as msg (msg.id)} + + {#if msg.role === "user"} +
+
+ {#if msg.attachments?.length} +
+ {#each msg.attachments as att} + {att.name} + {/each} +
+ {/if} + {#if msg.content} +
+
+ {msg.content} +
+ {#if status === "running" && !generating} +
+ +
+ {/if} +
+ {/if} +
+
+ + + {:else if msg.role === "assistant"} +
+ {#if msg.pending} +
+ + + + +
+ {:else} +
+ AI +
+ {/if} + +
+ + {#if msg.thinking || (msg.pending && msg.content === "" && !msg.thinking && !msg.toolUses?.length && !msg.leadIn?.trim())} +
+ + {#if msg.thinkOpen && msg.thinking} +
+ +
+ {/if} +
+ {/if} + + + {#if cleanLeadInForDisplay(msg.leadIn ?? "", !!msg.toolUses?.length)} +
+ {cleanLeadInForDisplay(msg.leadIn ?? "", !!msg.toolUses?.length)} +
+ {/if} + + + {#if msg.toolUses?.length} +
+ {#each msg.toolUses as tu, tuIdx} + onUpdateToolUse(msg.id, tuIdx, { expanded: !tu.expanded })} + onCancel={() => onCancelToolCall(msg.id, tuIdx, tu.toolCallId)} + /> + {/each} +
+ {/if} + + + {#if msg.content.trim()} +
+
+ +
+ {#if !msg.pending && msg.content} +
+ + {#if msg.id === messages[messages.length - 1]?.id && status === "running" && !generating} + + {/if} +
+ {/if} +
+ {/if} + + + {#if msg.pending && generating && streamTokens > 0} + {@const elapsedSec = (performance.now() - streamStartMs) / 1000} + {@const tokSec = elapsedSec > 0.1 ? (streamTokens / elapsedSec).toFixed(1) : "…"} + + {tokSec} {t("chat.tokSec")} + + {/if} + + + {#if !msg.pending && (msg.elapsed !== undefined || msg.usage)} +
+ {#if msg.elapsed !== undefined} + + {fmtMs(msg.elapsed)} + {#if msg.ttft !== undefined} · {t("chat.firstToken")} {fmtMs(msg.ttft)}{/if} + {#if msg.usage} + · {msg.usage.prompt_tokens}+{msg.usage.completion_tokens} {t("chat.tok")} + {#if msg.elapsed && msg.usage.completion_tokens > 0} + · {(msg.usage.completion_tokens / (msg.elapsed / 1000)).toFixed(1)} {t("chat.tokSec")} + {/if} + {/if} + + {/if} + {#if msg.usage && msg.usage.n_ctx > 0} + {@const usedPct = Math.round((msg.usage.total_tokens / msg.usage.n_ctx) * 100)} + {@const barColor = usedPct >= 90 ? "bg-red-500" + : usedPct >= 70 ? "bg-amber-500" + : "bg-emerald-500"} +
+
+
+
+ + {msg.usage.total_tokens}/{msg.usage.n_ctx} + +
+ {/if} +
+ {/if} +
+
+ {/if} + {/each} + {/if} + +
+ + +{#if !pinned} + +{/if} +
diff --git a/src/lib/chat/ChatSettingsPanel.svelte b/src/lib/chat/ChatSettingsPanel.svelte new file mode 100644 index 000000000..f35e285d8 --- /dev/null +++ b/src/lib/chat/ChatSettingsPanel.svelte @@ -0,0 +1,219 @@ + + + + + +
+ + +
+
+ +
+ + {t("chat.systemPrompt.chars", { n: systemPrompt.length })} + + {#if !isDefaultPrompt} + + {/if} +
+
+ + + + +
+ + {t("chat.systemPrompt.presets")} + +
+ {#each SYSTEM_PROMPT_PRESETS as preset} + {@const isActive = systemPrompt.trim() === preset.prompt.trim()} + + {/each} +
+
+
+ + +
+
+ + + + + {t("chat.eeg.contextLabel")} + +
+ +
+ + + {#if eegActive && latestBands} + {@const b = latestBands} + {@const n = b.channels.length || 1} + {@const rA = b.channels.reduce((s,c)=>s+c.rel_alpha,0)/n} + {@const rB = b.channels.reduce((s,c)=>s+c.rel_beta,0)/n} + {@const rT = b.channels.reduce((s,c)=>s+c.rel_theta,0)/n} +
+ {#each [ + { label: "SNR", val: (b.snr ?? 0).toFixed(1) + "dB" }, + { label: "Mood", val: (b.mood ?? 0).toFixed(0) + "/100" }, + { label: "α", val: (rA*100).toFixed(0) + "%" }, + { label: "β/α", val: (b.bar ?? 0).toFixed(2) }, + { label: "θ/α", val: (b.tar ?? 0).toFixed(2) }, + { label: "FAA", val: (b.faa>=0?"+":"")+b.faa.toFixed(2) }, + ...(b.hr != null ? [{ label: "HR", val: b.hr.toFixed(0)+"bpm" }] : []), + ...(b.meditation != null ? [{ label: "Med", val: b.meditation.toFixed(0)+"/100" }] : []), + ] as s} +
+ + {s.label} + + + {s.val} + +
+ {/each} +
+ {/if} + + +
+ + {t("chat.thinkDepth")} + +
+ {#each THINKING_LEVELS as lvl} + + {/each} +
+

+ {t(`chat.think.${thinkingLevel}Desc`)} +

+
+ + +
+ {#each [ + { labelKey: "chat.param.temperature", min: 0, max: 2, step: 0.05, value: temperature, set: (v: number) => temperature = v }, + { labelKey: "chat.param.maxTokens", min: 64, max: 8192, step: 64, value: maxTokens, set: (v: number) => maxTokens = v }, + { labelKey: "chat.param.topK", min: 1, max: 200, step: 1, value: topK, set: (v: number) => topK = v }, + { labelKey: "chat.param.topP", min: 0, max: 1, step: 0.05, value: topP, set: (v: number) => topP = v }, + ] as s} +
+
+ {t(s.labelKey)} + {s.value} +
+ s.set(+(e.target as HTMLInputElement).value)} + class="w-full accent-violet-500 h-1 cursor-pointer" /> +
+ {/each} +
+
diff --git a/src/lib/chat/ChatSidebar.svelte b/src/lib/chat/ChatSidebar.svelte new file mode 100644 index 000000000..f0a5bfbd7 --- /dev/null +++ b/src/lib/chat/ChatSidebar.svelte @@ -0,0 +1,406 @@ + + + + + + +
+ + +
+ + {t("chat.sidebar.chats")} + + +
+ + +
+ + {#if sessions.length === 0 && !showArchive} +

+ {t("chat.sidebar.empty")} +

+ {:else} +
    + {#each sessions as s (s.id)} + {@const isActive = s.id === activeId} + {@const isEditing = editingId === s.id} + +
  • +
    { if (!isEditing) onSelect(s.id); }} + ondblclick={(e) => startEdit(s, e)} + onkeydown={(e) => { + if (!isEditing && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onSelect(s.id); + } + }} + title={isEditing ? undefined : (s.title || displayLabel(s))} + class="group w-full text-left flex items-start gap-0 px-3 py-2 transition-colors + {isActive + ? 'bg-violet-500/10 dark:bg-violet-500/15' + : 'hover:bg-muted dark:hover:bg-white/[0.04]'} + cursor-pointer relative"> + + {#if isActive} + + {/if} + +
    + {#if isEditing} + { + if (e.key === "Enter") { e.preventDefault(); commitEdit(); } + else cancelEdit(e); + }} + onclick={(e) => e.stopPropagation()} + class="w-full text-ui-md font-medium bg-background border border-violet-500/40 + rounded px-1.5 py-0.5 text-foreground focus:outline-none + focus:ring-1 focus:ring-primary/50" + /> + {:else} +

    + {shortLabel(s)} +

    + {/if} + +
    + + {relTime(s.created_at)} + + {#if s.message_count > 0} + + {s.message_count} msg{s.message_count !== 1 ? "s" : ""} + + {/if} +
    +
    + + + {#if !isEditing} + + {/if} +
    +
  • + {/each} +
+ {/if} + + +
+ + + {#if showArchive} + {#if archived.length === 0} +

+ {t("chat.sidebar.archiveEmpty")} +

+ {:else} +
    + {#each archived as s (s.id)} +
  • +
    onSelect(s.id)} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(s.id); + } + }} + title={s.title || displayLabel(s)} + class="group w-full text-left flex items-start gap-0 px-3 py-1.5 transition-colors + hover:bg-muted dark:hover:bg-white/[0.04] + cursor-pointer relative opacity-60"> + +
    +

    + {shortLabel(s)} +

    +
    + + {relTime(s.created_at)} + + {#if s.message_count > 0} + + {s.message_count} msg{s.message_count !== 1 ? "s" : ""} + + {/if} +
    +
    + + +
    + + + + +
    +
    +
  • + {/each} +
+ {/if} + {/if} +
+
+
diff --git a/src/lib/chat/ChatToolCard.svelte b/src/lib/chat/ChatToolCard.svelte new file mode 100644 index 000000000..cc34fcfab --- /dev/null +++ b/src/lib/chat/ChatToolCard.svelte @@ -0,0 +1,391 @@ + + + + + +
+ +
+ + + + {#if tu.status === "calling"} + + {/if} +
+ + + {#if isDangerous && tu.status === 'calling' && dangerKey} +
+ + + + + + {t(dangerKey)} + +
+ {/if} + + + {#if tu.expanded && hasDetails} +
+ + {#if tu.tool === "bash" && bashCmd} +
+ + {t("chat.tools.commandLabel")} + +
{bashCmd}
+
+ + {:else if (tu.tool === "read_file" || tu.tool === "write_file" || tu.tool === "edit_file") && arg("path")} +
+ + {t("chat.tools.fileLabel")} + +
{arg("path")}
+ {#if tu.tool === "edit_file" && arg("old_text")} + + {t("chat.tools.editOldLabel")} + +
{arg("old_text")}
+ {/if} + {#if tu.tool === "edit_file" && arg("new_text")} + + {t("chat.tools.editNewLabel")} + +
{arg("new_text")}
+ {/if} + {#if tu.tool === "write_file" && arg("content")} + + {t("chat.tools.contentLabel")} + +
{arg("content")}
+ {/if} +
+ + {:else if tu.tool === "web_search" && arg("query")} +
+ + {t("chat.tools.queryLabel")} + +
{arg("query")}
+
+ + {#if sources.length} +
+ + {t("chat.tools.sourcesLabel")} + + {#each sources as src, si} + {@const expanded = expandedSources.has(si)} +
+ + + {#if expanded && src.preview} +
+ + {src.url} + +
{src.preview}
+
+ {/if} +
+ {/each} +
+ {/if} + + {:else if tu.tool === "web_fetch" && arg("url")} +
+ + URL + +
{arg("url")}
+
+ + {:else if hasNonEmptyArgs} +
+ + {t("chat.tools.argsLabel")} + +
{JSON.stringify(tu.args, null, 2)}
+
+ {/if} + + {#if tu.result} +
+ + {t("chat.tools.resultLabel")} + +
{#if typeof tu.result === "string"}{tu.result}{:else}{JSON.stringify(tu.result, null, 2)}{/if}
+
+ {/if} + + + {#if tu.status === "calling"} +
+ +
+ {/if} +
+ {/if} +
diff --git a/src/lib/chat/ChatToolsPanel.svelte b/src/lib/chat/ChatToolsPanel.svelte new file mode 100644 index 000000000..00d721646 --- /dev/null +++ b/src/lib/chat/ChatToolsPanel.svelte @@ -0,0 +1,213 @@ + + + + + +
+ +
+
+ + {t("chat.tools.label")} + + + {enabledToolCount}/9 + +
+
+ {#each [ + { key: "date" as const, icon: "🕐" }, + { key: "location" as const, icon: "📍" }, + { key: "web_search" as const, icon: "🔍" }, + { key: "web_fetch" as const, icon: "🌐" }, + { key: "bash" as const, icon: "💻" }, + { key: "read_file" as const, icon: "📄" }, + { key: "write_file" as const, icon: "✏️" }, + { key: "edit_file" as const, icon: "🔧" }, + { key: "skill_api" as const, icon: "🧠" }, + ] as tool} + + {/each} +
+ + + {#if toolConfig.bash} + + {/if} + + +
+
+ {t("chat.tools.executionMode")} +
+
+ {#each [ + { key: "parallel" as ToolExecutionMode, labelKey: "chat.tools.parallel" }, + { key: "sequential" as ToolExecutionMode, labelKey: "chat.tools.sequential" }, + ] as mode} + + {/each} +
+
+ + +
+
+ {t("llm.tools.maxRounds")} + {t("llm.tools.maxRoundsDesc")} +
+
+ {#each [1, 3, 5, 10] as val} + + {/each} +
+
+ + +
+
+ {t("llm.tools.maxCallsPerRound")} + {t("llm.tools.maxCallsPerRoundDesc")} +
+
+ {#each [1, 2, 4, 8] as val} + + {/each} +
+
+ + +
+
+ {t("llm.tools.contextCompression")} + {t("llm.tools.contextCompressionDesc")} +
+
+ {#each [ + { key: "off" as CompressionLevel, label: t("llm.tools.compressionOff") }, + { key: "normal" as CompressionLevel, label: t("llm.tools.compressionNormal") }, + { key: "aggressive" as CompressionLevel, label: t("llm.tools.compressionAggressive") }, + ] as opt} + + {/each} +
+
+ + +
+
+ {t("chat.tools.thinkingBudget")} + {t("chat.tools.thinkingBudgetDesc")} +
+
+ {#each TOOL_THINKING_LEVELS as lvl} + {@const isActive = toolConfig.thinking_budget === lvl.budget} + + {/each} +
+
+
+
diff --git a/src/lib/chat/ChatVoiceControls.svelte b/src/lib/chat/ChatVoiceControls.svelte new file mode 100644 index 000000000..9f64ca7ca --- /dev/null +++ b/src/lib/chat/ChatVoiceControls.svelte @@ -0,0 +1,198 @@ + + + + + +
+ + + + +
+ + +
+ + +
+ + +
+ + + {#if errorMsg} + + + + + {errorMsg} + + + {:else if phaseLabel} + + {#if phase === 'loading'} + + + + + {:else if phase === 'speaking'} + + {:else} + + + + + {/if} + {phaseLabel} + + {/if} +
diff --git a/src/lib/chat/PromptLibrary.svelte b/src/lib/chat/PromptLibrary.svelte new file mode 100644 index 000000000..408a6069a --- /dev/null +++ b/src/lib/chat/PromptLibrary.svelte @@ -0,0 +1,230 @@ + + + + + + + + + + + + + +{#if open} + +{/if} diff --git a/src/lib/chat/asr.ts b/src/lib/chat/asr.ts new file mode 100644 index 000000000..c38b0db68 --- /dev/null +++ b/src/lib/chat/asr.ts @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Shared voice-input (ASR + VAD) types and helpers for the chat window. + * + * The daemon owns the engine: `asr_start` / `asr_stop` / `asr_status` / + * `asr_set_ptt` Tauri commands drive it, and lifecycle + transcript events + * arrive on the `/v1/events` WebSocket as `"asr"` events (see `onDaemonEvent`). + * + * `settings.asr` (the persisted defaults) is owned by the daemon: `fetchAsrDefaults` + * reads it via `get_asr_settings` and `saveAsrDefaults` writes it via + * `set_asr_settings` (both hit settings.json, the source of truth). A + * `localStorage` copy is kept only as an instant-first-paint cache — + * `loadAsrDefaults` reads it synchronously on mount, then the async daemon fetch + * overwrites it. `asr_start` is still called with the resolved mode, so the + * daemon stays the source of truth at runtime too. + */ + +import { invoke } from "@tauri-apps/api/core"; + +export type AsrTrigger = "continuous" | "push_to_talk"; +export type AsrRouting = "voice_loop" | "transcribe_only"; + +/** Per-session voice-mode selection passed to `asr_start`. */ +export interface AsrMode { + trigger: AsrTrigger; + routing: AsrRouting; + language: string; +} + +/** Chat-side `settings.asr` defaults (mirrors the daemon `AsrConfig`). */ +export interface AsrDefaults { + enabled: boolean; + default_trigger: AsrTrigger; + default_routing: AsrRouting; + language: string; + /** ASR engine backend (e.g. "whisper"). */ + engine: string; + /** HuggingFace repo id of the speech-recognition model. */ + model: string; +} + +export const ASR_DEFAULTS_FALLBACK: AsrDefaults = { + enabled: true, + default_trigger: "continuous", + default_routing: "voice_loop", + language: "en", + engine: "whisper", + model: "openai/whisper-base.en", +}; + +export const ASR_DEFAULTS_KEY = "chat.asrDefaults"; + +/** Normalise a partial/untrusted object into a full `AsrDefaults`. */ +function coerceAsrDefaults(parsed: Partial | null | undefined): AsrDefaults { + return { + enabled: parsed?.enabled ?? ASR_DEFAULTS_FALLBACK.enabled, + default_trigger: parsed?.default_trigger ?? ASR_DEFAULTS_FALLBACK.default_trigger, + default_routing: parsed?.default_routing ?? ASR_DEFAULTS_FALLBACK.default_routing, + language: parsed?.language ?? ASR_DEFAULTS_FALLBACK.language, + engine: parsed?.engine ?? ASR_DEFAULTS_FALLBACK.engine, + model: parsed?.model ?? ASR_DEFAULTS_FALLBACK.model, + }; +} + +/** + * Synchronously read the cached ASR defaults for instant first paint. + * + * This is only the `localStorage` cache — the daemon (settings.json) is the + * source of truth. Callers should follow up with `fetchAsrDefaults()` on mount + * to reconcile against the daemon. + */ +export function loadAsrDefaults(): AsrDefaults { + try { + const raw = localStorage.getItem(ASR_DEFAULTS_KEY); + if (!raw) return { ...ASR_DEFAULTS_FALLBACK }; + return coerceAsrDefaults(JSON.parse(raw) as Partial); + } catch { + return { ...ASR_DEFAULTS_FALLBACK }; + } +} + +/** Daemon `get_asr_settings` response shape. */ +interface GetAsrSettingsResponse { + ok: boolean; + asr?: Partial; +} + +/** + * Load the authoritative ASR defaults from the daemon (settings.json). + * + * Falls back to the built-in defaults on any error. On success the result is + * also written to the `localStorage` cache so the next first paint is correct. + */ +export async function fetchAsrDefaults(): Promise { + try { + const res = await invoke("get_asr_settings"); + const defaults = coerceAsrDefaults(res?.asr); + cacheAsrDefaults(defaults); + return defaults; + } catch { + return { ...ASR_DEFAULTS_FALLBACK }; + } +} + +/** Write the ASR defaults to the `localStorage` first-paint cache. */ +function cacheAsrDefaults(defaults: AsrDefaults): void { + try { + localStorage.setItem(ASR_DEFAULTS_KEY, JSON.stringify(defaults)); + } catch {} +} + +/** + * Persist the ASR defaults to the daemon (settings.json) and the local cache. + * + * Best-effort: the daemon write is fire-and-forget (errors are swallowed) and + * the cache write ignores storage errors. + */ +export function saveAsrDefaults(defaults: AsrDefaults): void { + cacheAsrDefaults(defaults); + invoke("set_asr_settings", { config: defaults }).catch(() => {}); +} + +// ── Engine event payload (daemon `"asr"` WebSocket events) ──────────────────── + +export type AsrEventKind = + | "loading" + | "listening" + | "speech_start" + | "speech_end" + | "transcript" + | "error" + | "stopped" + | "assistant"; + +/** Payload of a daemon `"asr"` event (`DaemonEvent.payload`). */ +export interface AsrEventPayload { + kind: AsrEventKind; + // transcript + text?: string; + is_final?: boolean; + // error + message?: string; + // assistant + spoken?: boolean; +} + +/** Visual listening state derived from the event stream. */ +export type AsrPhase = "idle" | "loading" | "listening" | "speaking"; + +/** Status returned by the `asr_status` command. */ +export interface AsrStatus { + running: boolean; + available: boolean; + trigger: AsrTrigger | null; + routing: AsrRouting | null; + language: string | null; + ptt_active: boolean; +} diff --git a/src/lib/chat/chat-eeg.ts b/src/lib/chat/chat-eeg.ts new file mode 100644 index 000000000..280f60593 --- /dev/null +++ b/src/lib/chat/chat-eeg.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * EEG context injection — builds a compact brain-state summary block + * suitable for injection into an LLM system prompt. + * + * Extracted from `routes/chat/+page.svelte`. + */ + +import type { BandPowers, BandSnapshot } from "$lib/chat/chat-types"; + +/** + * Build a compact EEG brain-state block suitable for injection into a + * system prompt. Averages relative band powers across all channels. + */ +export function buildEegBlock(b: BandSnapshot): string { + const n = b.channels.length || 1; + const avg = (key: keyof BandPowers) => b.channels.reduce((s, ch) => s + (ch[key] as number), 0) / n; + + const pct = (v: number) => `${(v * 100).toFixed(0)}%`; + const f1 = (v: number) => v.toFixed(1); + const f2 = (v: number) => (v >= 0 ? "+" : "") + v.toFixed(2); + + const rD = avg("rel_delta"); + const rT = avg("rel_theta"); + const rA = avg("rel_alpha"); + const rB = avg("rel_beta"); + const rG = avg("rel_gamma"); + + const dominant = b.channels[0]?.dominant ?? "—"; + + const lines: string[] = [ + "--- Live EEG Brain State (auto-updated) ---", + `Signal quality (SNR): ${f1(b.snr ?? 0)} dB | Dominant band: ${dominant}`, + `Relative band powers: δ=${pct(rD)} θ=${pct(rT)} α=${pct(rA)} β=${pct(rB)} γ=${pct(rG)}`, + `Mood: ${(b.mood ?? 0).toFixed(0)}/100 | FAA (approach): ${f2(b.faa)}`, + `TAR (θ/α — drowsiness): ${f1(b.tar ?? 0)} | BAR (β/α — focus/stress): ${f1(b.bar ?? 0)}`, + `Coherence (α sync): ${((b.coherence ?? 0) * 100).toFixed(0)}% | Consciousness: wakefulness=${(b.consciousness_wakefulness ?? 0).toFixed(0)} integration=${(b.consciousness_integration ?? 0).toFixed(0)}`, + ]; + + if (b.meditation != null) lines.push(`Meditation: ${b.meditation.toFixed(0)}/100`); + if (b.cognitive_load != null) lines.push(`Cognitive load: ${b.cognitive_load.toFixed(0)}/100`); + if (b.drowsiness != null) lines.push(`Drowsiness: ${b.drowsiness.toFixed(0)}/100`); + if (b.hr != null) + lines.push( + `Heart rate: ${b.hr.toFixed(0)} bpm${b.rmssd != null ? ` | HRV (RMSSD): ${b.rmssd.toFixed(1)} ms` : ""}`, + ); + if (b.stress_index != null) lines.push(`Stress index: ${b.stress_index.toFixed(1)}`); + if (b.respiratory_rate != null) lines.push(`Respiratory rate: ${b.respiratory_rate.toFixed(1)} breaths/min`); + + lines.push("--- End EEG State ---"); + return lines.join("\n"); +} diff --git a/src/lib/chat/chat-types.ts b/src/lib/chat/chat-types.ts new file mode 100644 index 000000000..f62b2e383 --- /dev/null +++ b/src/lib/chat/chat-types.ts @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Shared type definitions for the chat feature. + * + * Extracted from `routes/chat/+page.svelte` to reduce file size and + * allow reuse across chat sub-components. + */ + +// ── Core types ────────────────────────────────────────────────────────────── + +export type Role = "user" | "assistant" | "system"; +export type ServerStatus = "stopped" | "loading" | "running"; + +export interface ToolUseEvent { + tool: string; + status: string; // "calling" | "done" | "error" | "approval_required" | "cancelled" + detail?: string; + toolCallId?: string; + args?: Record; // structured arguments from tool_execution_start + result?: unknown; // structured result from tool_execution_end + expanded?: boolean; // UI toggle +} + +export interface Attachment { + dataUrl: string; + mimeType: string; + name: string; +} + +export interface UsageInfo { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + n_ctx: number; +} + +export interface Message { + id: number; + role: Role; + content: string; + /** Assistant text emitted before a block, shown as its own bubble */ + leadIn?: string; + /** Images attached to a user message */ + attachments?: Attachment[]; + /** Chain-of-thought text between (stripped from content) */ + thinking?: string; + /** Whether the thinking block is expanded in the UI */ + thinkOpen?: boolean; + /** True while we're streaming tokens in */ + pending?: boolean; + /** ms taken for first token */ + ttft?: number; + /** ms for full response */ + elapsed?: number; + /** Token usage from the final SSE chunk */ + usage?: UsageInfo; + /** Tool calls made during this response */ + toolUses?: ToolUseEvent[]; +} + +// ── Server status payload ─────────────────────────────────────────────────── + +export interface ServerStatusPayload { + status: ServerStatus; + model_name: string; + model?: string; + supports_vision?: boolean; +} + +// ── IPC streaming types (mirror Rust ChatChunk) ───────────────────────────── + +export interface ChatChunkDelta { + type: "delta"; + content: string; +} +export interface ChatChunkToolUse { + type: "tool_use"; + tool: string; + status: string; + detail?: string; +} +export interface ChatChunkToolExecStart { + type: "tool_execution_start"; + tool_call_id: string; + tool_name: string; + args: Record; +} +export interface ChatChunkToolExecEnd { + type: "tool_execution_end"; + tool_call_id: string; + tool_name: string; + result: unknown; + is_error: boolean; +} +export interface ChatChunkToolCancelled { + type: "tool_cancelled"; + tool_call_id: string; + tool_name: string; +} +export interface ChatChunkDone { + type: "done"; + finish_reason: string; + prompt_tokens: number; + completion_tokens: number; + n_ctx: number; +} +export interface ChatChunkError { + type: "error"; + message: string; +} +export type ChatChunk = + | ChatChunkDelta + | ChatChunkToolUse + | ChatChunkToolExecStart + | ChatChunkToolExecEnd + | ChatChunkToolCancelled + | ChatChunkDone + | ChatChunkError; + +// ── Thinking budget ───────────────────────────────────────────────────────── + +export type ThinkingLevel = "minimal" | "normal" | "extended" | "unlimited"; + +export const THINKING_LEVELS: { labelKey: string; key: ThinkingLevel; budget: number | null }[] = [ + { labelKey: "chat.think.minimal", key: "minimal", budget: 512 }, + { labelKey: "chat.think.normal", key: "normal", budget: 2048 }, + { labelKey: "chat.think.extended", key: "extended", budget: 8192 }, + { labelKey: "chat.think.unlimited", key: "unlimited", budget: null }, +]; + +// ── Tool configuration ────────────────────────────────────────────────────── + +export type ToolExecutionMode = "sequential" | "parallel"; +export type CompressionLevel = "off" | "normal" | "aggressive"; + +export interface ToolContextCompression { + level: CompressionLevel; + max_search_results: number; + max_result_chars: number; +} + +export const DEFAULT_TOOL_CONTEXT_COMPRESSION: ToolContextCompression = { + level: "normal", + max_search_results: 0, + max_result_chars: 0, +}; + +export interface ToolConfig { + enabled: boolean; + date: boolean; + location: boolean; + web_search: boolean; + web_fetch: boolean; + bash: boolean; + require_bash_edit: boolean; + read_file: boolean; + write_file: boolean; + edit_file: boolean; + skill_api: boolean; + execution_mode: ToolExecutionMode; + max_rounds: number; + max_calls_per_round: number; + thinking_budget: number | null; + context_compression: ToolContextCompression; +} + +export type ToolThinkingLevel = "chat" | "none" | "minimal" | "normal" | "extended"; + +export const TOOL_THINKING_LEVELS: { labelKey: string; key: ToolThinkingLevel; budget: number | null }[] = [ + { labelKey: "chat.tools.thinkChat", key: "chat", budget: null }, + { labelKey: "chat.tools.thinkNone", key: "none", budget: 0 }, + { labelKey: "chat.tools.thinkMinimal", key: "minimal", budget: 256 }, + { labelKey: "chat.tools.thinkNormal", key: "normal", budget: 1024 }, + { labelKey: "chat.tools.thinkExtended", key: "extended", budget: 4096 }, +]; + +export const DEFAULT_TOOL_CONFIG: ToolConfig = { + enabled: true, + date: true, + location: true, + web_search: true, + web_fetch: true, + bash: false, + require_bash_edit: false, + read_file: false, + write_file: false, + edit_file: false, + skill_api: true, + execution_mode: "parallel", + max_rounds: 3, + max_calls_per_round: 4, + thinking_budget: null, + context_compression: { ...DEFAULT_TOOL_CONTEXT_COMPRESSION }, +}; + +// ── Stored-message type (mirrors Rust StoredMessage) ──────────────────────── + +export interface StoredToolCallRow { + id: number; + message_id: number; + tool: string; + status: string; + detail?: string | null; + tool_call_id?: string | null; + args?: Record; + result?: unknown; + created_at: number; +} + +export interface StoredMessage { + id: number; + session_id: number; + role: string; + content: string; + thinking: string | null; + created_at: number; + tool_calls: StoredToolCallRow[]; +} + +export interface ChatSessionResponse { + session_id: number; + messages: StoredMessage[]; +} + +// ── System prompt presets ─────────────────────────────────────────────────── + +export const SYSTEM_PROMPT_DEFAULT = "You are a helpful assistant."; +export const SYSTEM_PROMPT_KEY = "chat.systemPrompt"; + +export const SYSTEM_PROMPT_PRESETS: { key: string; icon: string; prompt: string }[] = [ + { + key: "default", + icon: "🤖", + prompt: "You are a helpful assistant.", + }, + { + key: "coach", + icon: "🧘", + prompt: + "You are a neurofeedback coach specialising in relaxation and stress reduction. " + + "Give practical, evidence-based advice grounded in the user's live EEG data. " + + "Be encouraging, clear, and concise.", + }, + { + key: "focus", + icon: "🎯", + prompt: + "You are a focus and cognitive-performance coach. " + + "Help the user optimise their attention, working memory, and mental endurance " + + "using insights from their EEG readings. Offer actionable protocols.", + }, + { + key: "educator", + icon: "📚", + prompt: + "You are a neuroscience educator. " + + "Explain brain-wave patterns and EEG metrics in plain, accessible language. " + + "Use analogies freely and avoid unnecessary jargon.", + }, + { + key: "sleep", + icon: "😴", + prompt: + "You are a sleep and recovery specialist. " + + "Interpret the user's EEG data to provide personalised guidance on improving " + + "sleep quality, recovery, and circadian regulation.", + }, + { + key: "mindfulness", + icon: "🌿", + prompt: + "You are a mindfulness and meditation guide. " + + "Use the user's real-time brainwave data to suggest meditation techniques, " + + "breathing exercises, and awareness practices tailored to their current state.", + }, +]; + +// ── EEG band snapshot — re-exported from the canonical BandChart module ───── +// The full BandPowers / BandSnapshot interfaces live in BandChart.svelte. +// Re-export here so chat-related code doesn't need to import from a UI component. +export type { BandPowers, BandSnapshot } from "$lib/charts/BandChart.svelte"; + +// ── Conversion helpers ────────────────────────────────────────────────────── + +export function storedToMessage(sm: StoredMessage, idCounter: { value: number }): Message { + const msg: Message = { + id: ++idCounter.value, + role: sm.role as Role, + content: sm.content, + thinking: sm.thinking ?? undefined, + thinkOpen: false, + pending: false, + }; + if (sm.tool_calls && sm.tool_calls.length > 0) { + msg.toolUses = sm.tool_calls.map( + (tc: StoredToolCallRow): ToolUseEvent => ({ + tool: tc.tool, + status: tc.status, + detail: tc.detail ?? undefined, + toolCallId: tc.tool_call_id ?? undefined, + args: tc.args ?? undefined, + result: tc.result ?? undefined, + expanded: false, + }), + ); + } + return msg; +} + +/** A text segment in a multi-part user message. */ +export interface TextContentPart { + type: "text"; + text: string; +} +/** An image segment in a multi-part user message. */ +export interface ImageContentPart { + type: "image_url"; + image_url: { url: string }; +} +/** Union of all multi-part content segments. */ +export type ContentPart = TextContentPart | ImageContentPart; + +/** + * Build the content field for a user message (plain string or parts array). + * If images are present, returns a multi-part content array for the API. + */ +export function buildUserContent(text: string, imgs: Attachment[]): string | ContentPart[] { + if (imgs.length === 0) return text; + const parts: ContentPart[] = []; + if (text.trim()) parts.push({ type: "text", text }); + for (const img of imgs) { + parts.push({ type: "image_url", image_url: { url: img.dataUrl } }); + } + return parts; +} + +/** + * Rough token estimate: ~4 chars per token, ~1 token overhead. + */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4) + 1; +} diff --git a/src/lib/chat/chat-utils.ts b/src/lib/chat/chat-utils.ts new file mode 100644 index 000000000..81a13fb6a --- /dev/null +++ b/src/lib/chat/chat-utils.ts @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Pure utility functions for the chat page — tool-call parsing, + * danger detection, and assistant output parsing. + * + * Extracted from `routes/chat/+page.svelte` to reduce file size and + * allow independent unit testing. + */ + +// ── Tool-call stripping ───────────────────────────────────────────────────── + +/** Known built-in tool names — must stay in sync with KNOWN_TOOL_NAMES in tools.rs */ +const KNOWN_TOOLS = new Set([ + "date", + "location", + "web_search", + "web_fetch", + "bash", + "read_file", + "write_file", + "edit_file", + "search_output", +]); + +function isToolCallObject(v: Record): boolean { + if (("name" in v || "tool" in v || "tool_calls" in v) && ("parameters" in v || "arguments" in v || "tool_calls" in v)) + return true; + const keys = Object.keys(v); + return ( + keys.length > 0 && + keys.some((k) => KNOWN_TOOLS.has(k)) && + Object.values(v).every((val) => typeof val === "object" && val !== null) + ); +} + +function looksLikeToolCallJsonPrefix(s: string): boolean { + const trimmed = s.trimStart(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false; + + const probe = trimmed.slice(0, 320).toLowerCase(); + const isDictStyle = [...KNOWN_TOOLS].some((name) => probe.includes(`"${name}":`) || probe.includes(`"${name}": `)); + if (isDictStyle) return true; + + const mentionsToolName = + probe.includes('"name"') || + probe.includes('"tool"') || + probe.includes('"tool_calls"') || + probe.includes('"function"'); + const mentionsArgs = probe.includes('"parameter') || probe.includes('"argument') || probe.includes(""); + + return mentionsToolName && mentionsArgs; +} + +function isToolCallArray(v: unknown): boolean { + if (!Array.isArray(v)) return false; + return v.some( + (item) => + typeof item === "object" && + item !== null && + !Array.isArray(item) && + isToolCallObject(item as Record), + ); +} + +/** + * Strip tool-call JSON code fences that leaked into rawAcc. + * + * The streaming sanitizer on the Rust side holds back tool-call JSON, but + * it can only recognise a fence as a tool call once it has seen BOTH a + * "tool"/"name" key AND a "parameters"/"arguments" key. Tokens emitted + * before that threshold are already in rawAcc and need to be cleaned here. + */ +export function stripToolCallFences(raw: string): string { + // 1. Complete fenced blocks + let s = raw.replace(/```(?:json)?\n([\s\S]*?)\n?```/g, (match, body: string) => { + const trimmedBody = body.trim(); + try { + const v = JSON.parse(trimmedBody); + if (typeof v === "object" && v !== null && !Array.isArray(v) && isToolCallObject(v)) return ""; + if (isToolCallArray(v)) return ""; + } catch { + /* not JSON — keep */ + } + if (looksLikeToolCallJsonPrefix(trimmedBody)) return ""; + return match; + }); + + // 2a. Incomplete fence immediately before a tag. + s = s.replace(/```(?:json)?\n([\s\S]*?)(?=\n*)/g, (match, body: string) => + looksLikeToolCallJsonPrefix(body) ? "" : match, + ); + + // 2b. Incomplete fence at end of string (still streaming). + s = s.replace(/```(?:json)?\n([\s\S]*)$/g, (match, body: string) => (looksLikeToolCallJsonPrefix(body) ? "" : match)); + + // 3. Bare inline tool-call JSON (not fenced) + s = s.replace(/(?:^|\n)\s*[[{][\s\S]*$/gm, (match) => { + if (looksLikeToolCallJsonPrefix(match.trim())) return ""; + return match; + }); + + // 4. Complete [TOOL_CALL]…[/TOOL_CALL] blocks + s = s.replace(/\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/g, ""); + + // 5. Incomplete [TOOL_CALL] at end of string (mid-stream) + s = s.replace(/\[TOOL_C[\s\S]*$/g, ""); + + return s; +} + +// ── Lead-in cleaning ──────────────────────────────────────────────────────── + +export function cleanAssistantLeadIn(raw: string): string { + return raw + .replace(/```[a-z]*\s*/gi, "") + .split("\n") + .filter((line) => !/^\s*(json|copy)\s*$/i.test(line)) + .join("\n") + .trim(); +} + +/** + * Clean lead-in text for display. When tool calls are active, aggressively + * strip ALL incomplete code fences and any JSON-like fragments. + */ +export function cleanLeadInForDisplay(raw: string, hasToolUses: boolean): string { + if (!raw.trim()) return ""; + + let s = stripToolCallFences(raw); + + if (hasToolUses) { + s = s.replace(/```[a-z]*\n[\s\S]*$/gi, ""); + s = s.replace(/\n\s*[[{][\s\S]*$/g, ""); + } + + return s.trim(); +} + +// ── Danger detection ──────────────────────────────────────────────────────── + +/** Bash patterns that indicate a potentially dangerous command (mirrored from Rust). */ +const DANGEROUS_BASH_PATTERNS = [ + "rm ", + "rm\t", + "rmdir", + "shred", + "mkfs", + "dd if=", + "dd of=", + "sudo ", + "su -", + "su\t", + "> /dev/", + "chmod", + "chown", + "kill ", + "killall", + "pkill", + "shutdown", + "reboot", + "halt", + "poweroff", + "systemctl stop", + "systemctl disable", + ":(){ :|:& };:", + "/etc/", + "/boot/", + "/usr/", + "/var/", + "/sys/", + "/proc/", +]; + +/** Sensitive path prefixes (mirrored from Rust). */ +const SENSITIVE_PATH_PREFIXES = [ + "/etc/", + "/boot/", + "/usr/", + "/var/", + "/sys/", + "/proc/", + "/bin/", + "/sbin/", + "/lib/", + "/opt/", +]; + +/** + * Check if a tool call looks dangerous based on its name and arguments. + * Returns a danger reason i18n key, or null if safe. + * + * Accepts any object with `tool` and optional `args` fields. + */ +export function detectToolDanger(tu: { tool: string; args?: Record }): string | null { + if (tu.tool === "bash" && typeof tu.args?.command === "string") { + const cmd = tu.args.command.toLowerCase(); + for (const pat of DANGEROUS_BASH_PATTERNS) { + if (cmd.includes(pat)) { + return `chat.tools.dangerBash`; + } + } + } + if (["write_file", "edit_file", "read_file"].includes(tu.tool) && typeof tu.args?.path === "string") { + const path = tu.args.path; + for (const prefix of SENSITIVE_PATH_PREFIXES) { + if (path.startsWith(prefix)) { + return `chat.tools.dangerPath`; + } + } + } + return null; +} + +// ── Assistant output parsing ──────────────────────────────────────────────── + +/** + * Split raw assistant output into three display zones: + * - `leadIn` = inter-think text segments (all except the last) + * - `thinking` = merged blocks + * - `content` = the final answer (last non-empty segment) + */ +export function parseAssistantOutput(raw: string): { + leadIn: string; + thinking: string; + content: string; +} { + const s = stripToolCallFences(raw); + + if (!s.includes("")) return { leadIn: "", thinking: "", content: s }; + + const thinkingParts: string[] = []; + let withoutThink = s.replace(/([\s\S]*?)<\/think>/g, (_: string, inner: string) => { + thinkingParts.push(inner.trim()); + return "\x00"; + }); + + // Handle an unclosed at the end (still streaming) + const openIdx = withoutThink.indexOf(""); + if (openIdx !== -1) { + thinkingParts.push(withoutThink.slice(openIdx + 7).trim()); + withoutThink = withoutThink.slice(0, openIdx); + } + + const thinking = thinkingParts.join("\n\n"); + + const parts = withoutThink + .split("\x00") + .map((p: string) => p.trim()) + .filter(Boolean); + + if (parts.length === 0) return { leadIn: "", thinking, content: "" }; + + const content = parts[parts.length - 1]; + const leadIn = parts.slice(0, -1).map(cleanAssistantLeadIn).filter(Boolean).join("\n\n"); + + return { leadIn, thinking, content }; +} diff --git a/src/lib/chat/index.ts b/src/lib/chat/index.ts new file mode 100644 index 000000000..66ec9a217 --- /dev/null +++ b/src/lib/chat/index.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. + +// Re-export chat types and utilities for convenient imports +export type { Attachment, Message, Role, UsageInfo } from "./chat-types"; diff --git a/src/lib/compare/compare-canvas.ts b/src/lib/compare/compare-canvas.ts new file mode 100644 index 000000000..f8552b905 --- /dev/null +++ b/src/lib/compare/compare-canvas.ts @@ -0,0 +1,493 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Canvas drawing functions for the compare page — spectrum bars, diff chart, + * radar, heatmaps. + * + * Extracted from `routes/compare/+page.svelte`. + */ + +import { bandKeys, bandMeta, radarMetrics } from "$lib/compare/compare-types"; +import type { EpochRow, SessionMetrics } from "$lib/dashboard/SessionDetail.svelte"; +import { setupHiDpiCanvas } from "$lib/format"; + +// ── Heatmap definitions ────────────────────────────────────────────────────── + +export const HM_BANDS_DEF = [ + { + key: "rd" as const, + sym: "δ", + lo: [20, 20, 50] as [number, number, number], + hi: [99, 102, 241] as [number, number, number], + }, + { + key: "rt" as const, + sym: "θ", + lo: [25, 15, 55] as [number, number, number], + hi: [139, 92, 246] as [number, number, number], + }, + { + key: "ra" as const, + sym: "α", + lo: [10, 40, 20] as [number, number, number], + hi: [34, 197, 94] as [number, number, number], + }, + { + key: "rb" as const, + sym: "β", + lo: [10, 30, 60] as [number, number, number], + hi: [59, 130, 246] as [number, number, number], + }, + { + key: "rg" as const, + sym: "γ", + lo: [55, 45, 10] as [number, number, number], + hi: [245, 158, 11] as [number, number, number], + }, +] as const; + +export const HM_SCORES_DEF = [ + { + key: "relaxation" as const, + sym: "Rlx", + lo: [10, 45, 35] as [number, number, number], + hi: [16, 185, 129] as [number, number, number], + }, + { + key: "engagement" as const, + sym: "Eng", + lo: [55, 45, 10] as [number, number, number], + hi: [245, 158, 11] as [number, number, number], + }, + { + key: "med" as const, + sym: "Med", + lo: [35, 15, 70] as [number, number, number], + hi: [139, 92, 246] as [number, number, number], + }, + { + key: "cog" as const, + sym: "Cog", + lo: [10, 35, 55] as [number, number, number], + hi: [14, 165, 233] as [number, number, number], + }, + { + key: "drow" as const, + sym: "Drw", + lo: [55, 10, 10] as [number, number, number], + hi: [239, 68, 68] as [number, number, number], + }, +] as const; + +export const HEATMAP_ROW_H = 14; +export const HEATMAP_LABEL_W = 22; + +// ── Primitives ─────────────────────────────────────────────────────────────── + +export function lerpRgba(lo: [number, number, number], hi: [number, number, number], t: number, alpha: number): string { + const r = Math.round(lo[0] + (hi[0] - lo[0]) * t); + const g = Math.round(lo[1] + (hi[1] - lo[1]) * t); + const b = Math.round(lo[2] + (hi[2] - lo[2]) * t); + return `rgba(${r},${g},${b},${alpha})`; +} + +function roundedBar(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + if (h < r * 2) r = h / 2; + if (r < 0) r = 0; + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h); + ctx.lineTo(x, y + h); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +// ── Spectrum bar ───────────────────────────────────────────────────────────── + +export function drawSpectrum(canvas: HTMLCanvasElement, m: SessionMetrics, _label: string) { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + const ctx = setupHiDpiCanvas(canvas, w, h); + + const vals = bandKeys.map((k) => m[k] ?? 0); + let sum = vals.reduce((a, b) => a + b, 0); + if (sum < 1e-6) sum = 1; + + const barY = 0, + barH = h, + r = 8; + ctx.save(); + ctx.beginPath(); + roundRect(ctx, 0, barY, w, barH, r); + ctx.clip(); + + let x = 0; + for (let i = 0; i < 5; i++) { + const segW = (vals[i] / sum) * w; + ctx.fillStyle = bandMeta[i].color; + ctx.globalAlpha = 0.82; + ctx.fillRect(x, barY, segW + 0.5, barH); + x += segW; + } + + ctx.globalAlpha = 0.38; + ctx.fillStyle = "#000"; + ctx.fillRect(0, barY, w, barH); + ctx.globalAlpha = 1; + + x = 0; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + for (let i = 0; i < 5; i++) { + const segW = (vals[i] / sum) * w; + if (segW > 32) { + ctx.font = "bold 11px ui-sans-serif, system-ui, sans-serif"; + ctx.fillStyle = "#fff"; + ctx.globalAlpha = 0.95; + ctx.fillText(`${bandMeta[i].sym} ${Math.round((vals[i] / sum) * 100)}%`, x + segW / 2, barH / 2); + } + x += segW; + } + ctx.restore(); +} + +// ── Diff chart ─────────────────────────────────────────────────────────────── + +export function drawDiffChart(canvas: HTMLCanvasElement, a: SessionMetrics, b: SessionMetrics) { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + const ctx = setupHiDpiCanvas(canvas, w, h); + + const ml = 4, + mr = 4, + mt = 14, + mb = 16; + const cw = w - ml - mr; + const ch = h - mt - mb; + + const valsA = bandKeys.map((k) => a[k] ?? 0); + const valsB = bandKeys.map((k) => b[k] ?? 0); + const maxVal = Math.max(...valsA, ...valsB, 0.01); + + const nBands = 5; + const groupW = cw / nBands; + const barW = groupW * 0.32; + const gap = groupW * 0.06; + + for (let i = 0; i < nBands; i++) { + const gx = ml + i * groupW; + + const hA = (valsA[i] / maxVal) * ch; + ctx.fillStyle = bandMeta[i].color; + ctx.globalAlpha = 0.9; + roundedBar(ctx, gx + groupW / 2 - barW - gap / 2, mt + ch - hA, barW, hA, 3); + ctx.fill(); + + const hB = (valsB[i] / maxVal) * ch; + ctx.globalAlpha = 0.45; + roundedBar(ctx, gx + groupW / 2 + gap / 2, mt + ch - hB, barW, hB, 3); + ctx.fill(); + + ctx.globalAlpha = 1; + ctx.font = "bold 9px ui-monospace, 'JetBrains Mono', monospace"; + ctx.fillStyle = bandMeta[i].color; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(bandMeta[i].sym, gx + groupW / 2, mt + ch + 4); + } + + ctx.font = "bold 8px ui-sans-serif, system-ui, sans-serif"; + ctx.textBaseline = "top"; + ctx.textAlign = "left"; + ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("color") || "#888"; + ctx.globalAlpha = 0.7; + ctx.fillText("A ■ B ▪", ml + 2, 2); + ctx.globalAlpha = 1; +} + +// ── Radar chart ────────────────────────────────────────────────────────────── + +export function drawRadar(canvas: HTMLCanvasElement, a: SessionMetrics, b: SessionMetrics) { + const w = canvas.clientWidth; + const h = canvas.clientHeight; + const ctx = setupHiDpiCanvas(canvas, w, h); + + const cx = w / 2, + cy = h / 2; + const radius = Math.min(cx, cy) - 28; + const n = radarMetrics.length; + const angleStep = (Math.PI * 2) / n; + + // Grid rings + for (let ring = 1; ring <= 4; ring++) { + const r = (ring / 4) * radius; + ctx.beginPath(); + for (let i = 0; i <= n; i++) { + const angle = i * angleStep - Math.PI / 2; + const x = cx + Math.cos(angle) * r; + const y = cy + Math.sin(angle) * r; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.strokeStyle = "rgba(128,128,128,0.12)"; + ctx.lineWidth = 0.5; + ctx.stroke(); + } + + // Axis lines + labels + ctx.font = "600 9px ui-sans-serif, system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + for (let i = 0; i < n; i++) { + const angle = i * angleStep - Math.PI / 2; + const ex = cx + Math.cos(angle) * radius; + const ey = cy + Math.sin(angle) * radius; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(ex, ey); + ctx.strokeStyle = "rgba(128,128,128,0.15)"; + ctx.lineWidth = 0.5; + ctx.stroke(); + + const lx = cx + Math.cos(angle) * (radius + 16); + const ly = cy + Math.sin(angle) * (radius + 16); + ctx.fillStyle = radarMetrics[i].color; + ctx.globalAlpha = 0.8; + ctx.fillText(radarMetrics[i].label, lx, ly); + ctx.globalAlpha = 1; + } + + function drawPoly(metrics: SessionMetrics, color: string, alpha: number) { + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const angle = i * angleStep - Math.PI / 2; + const val = Number(metrics[radarMetrics[i].key]) || 0; + const r = (Math.min(val, radarMetrics[i].max) / radarMetrics[i].max) * radius; + const x = cx + Math.cos(angle) * r; + const y = cy + Math.sin(angle) * r; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fillStyle = color; + ctx.globalAlpha = alpha * 0.2; + ctx.fill(); + ctx.strokeStyle = color; + ctx.globalAlpha = alpha; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.globalAlpha = 1; + + for (let i = 0; i < n; i++) { + const angle = i * angleStep - Math.PI / 2; + const val = Number(metrics[radarMetrics[i].key]) || 0; + const r = (Math.min(val, radarMetrics[i].max) / radarMetrics[i].max) * radius; + const x = cx + Math.cos(angle) * r; + const y = cy + Math.sin(angle) * r; + ctx.beginPath(); + ctx.arc(x, y, 2.5, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.globalAlpha = alpha; + ctx.fill(); + ctx.globalAlpha = 1; + } + } + + drawPoly(a, "#3b82f6", 0.9); + drawPoly(b, "#f59e0b", 0.65); +} + +// ── Heatmaps ───────────────────────────────────────────────────────────────── + +export function drawBandHeatmap(canvas: HTMLCanvasElement, ts: EpochRow[], dark: boolean) { + if (!canvas || ts.length < 2) return; + const rows = HM_BANDS_DEF; + const nRows = rows.length; + const cssH = HEATMAP_ROW_H * nRows; + const cssW = canvas.clientWidth; + if (cssW <= 0) return; + + const ctx = setupHiDpiCanvas(canvas, cssW, cssH); + ctx.fillStyle = dark ? "#0e0e1a" : "#f5f5fa"; + ctx.fillRect(0, 0, cssW, cssH); + + const plotW = cssW - HEATMAP_LABEL_W; + const nCols = ts.length; + const colW = plotW / nCols; + + for (let ri = 0; ri < nRows; ri++) { + const { key, sym, lo, hi } = rows[ri]; + const vals = ts.map((r) => r[key] as number); + const vMin = Math.min(...vals); + const vMax = Math.max(...vals); + const vRange = vMax - vMin || 1; + const y0 = ri * HEATMAP_ROW_H; + + ctx.font = "bold 8px ui-monospace, 'JetBrains Mono', monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = lerpRgba(lo, hi, 0.85, 0.9); + ctx.fillText(sym, HEATMAP_LABEL_W / 2, y0 + HEATMAP_ROW_H / 2); + + for (let ci = 0; ci < nCols; ci++) { + const t = Math.max(0, Math.min(1, (vals[ci] - vMin) / vRange)); + ctx.fillStyle = lerpRgba(lo, hi, t, 0.15 + t * 0.85); + ctx.fillRect(HEATMAP_LABEL_W + ci * colW, y0, colW + 0.5, HEATMAP_ROW_H); + } + + if (ri < nRows - 1) { + ctx.fillStyle = "rgba(0,0,0,0.35)"; + ctx.fillRect(HEATMAP_LABEL_W, y0 + HEATMAP_ROW_H - 0.5, plotW, 1); + } + } +} + +export function drawScoreHeatmap(canvas: HTMLCanvasElement, ts: EpochRow[], dark: boolean) { + if (!canvas || ts.length < 2) return; + const rows = HM_SCORES_DEF; + const nRows = rows.length; + const cssH = HEATMAP_ROW_H * nRows; + const cssW = canvas.clientWidth; + if (cssW <= 0) return; + + const ctx = setupHiDpiCanvas(canvas, cssW, cssH); + ctx.fillStyle = dark ? "#0e0e1a" : "#f5f5fa"; + ctx.fillRect(0, 0, cssW, cssH); + + const plotW = cssW - HEATMAP_LABEL_W; + const nCols = ts.length; + const colW = plotW / nCols; + + for (let ri = 0; ri < nRows; ri++) { + const { key, sym, lo, hi } = rows[ri]; + const vals = ts.map((r) => r[key] as number); + const vMin = Math.min(...vals); + const vMax = Math.max(...vals); + const vRange = vMax - vMin || 1; + const y0 = ri * HEATMAP_ROW_H; + + ctx.font = "bold 7px ui-monospace, 'JetBrains Mono', monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = lerpRgba(lo, hi, 0.85, 0.9); + ctx.fillText(sym, HEATMAP_LABEL_W / 2, y0 + HEATMAP_ROW_H / 2); + + for (let ci = 0; ci < nCols; ci++) { + const t = Math.max(0, Math.min(1, (vals[ci] - vMin) / vRange)); + ctx.fillStyle = lerpRgba(lo, hi, t, 0.15 + t * 0.85); + ctx.fillRect(HEATMAP_LABEL_W + ci * colW, y0, colW + 0.5, HEATMAP_ROW_H); + } + + if (ri < nRows - 1) { + ctx.fillStyle = "rgba(0,0,0,0.35)"; + ctx.fillRect(HEATMAP_LABEL_W, y0 + HEATMAP_ROW_H - 0.5, plotW, 1); + } + } +} + +export function drawBandDiffHeatmap(canvas: HTMLCanvasElement, tsA: EpochRow[], tsB: EpochRow[], dark: boolean) { + if (!canvas || tsA.length < 2 || tsB.length < 2) return; + const rows = HM_BANDS_DEF; + const nRows = rows.length; + const cssH = HEATMAP_ROW_H * nRows + 12; + const cssW = canvas.clientWidth; + if (cssW <= 0) return; + + const ctx = setupHiDpiCanvas(canvas, cssW, cssH); + ctx.fillStyle = dark ? "#0e0e1a" : "#f5f5fa"; + ctx.fillRect(0, 0, cssW, cssH); + + const nDisplay = Math.min(Math.max(tsA.length, tsB.length), 400); + const plotW = cssW - HEATMAP_LABEL_W; + const colW = plotW / nDisplay; + + function sampleIdx(tsLen: number, col: number): number { + return Math.min(Math.round((col / (nDisplay - 1)) * (tsLen - 1)), tsLen - 1); + } + + const BLUE_LO: [number, number, number] = [10, 50, 200]; + const BLUE_MID: [number, number, number] = [40, 80, 160]; + const NEUTRAL: [number, number, number] = dark ? [18, 18, 28] : [230, 230, 242]; + const RED_MID: [number, number, number] = [160, 40, 40]; + const RED_HI: [number, number, number] = [220, 30, 30]; + + function diffColor(d: number): string { + const absD = Math.abs(d); + const alpha = 0.12 + absD * 0.88; + if (d < 0) { + const t = Math.min(absD, 1); + return t < 0.5 ? lerpRgba(NEUTRAL, BLUE_MID, t * 2, alpha) : lerpRgba(BLUE_MID, BLUE_LO, (t - 0.5) * 2, alpha); + } else { + const t = Math.min(d, 1); + return t < 0.5 ? lerpRgba(NEUTRAL, RED_MID, t * 2, alpha) : lerpRgba(RED_MID, RED_HI, (t - 0.5) * 2, alpha); + } + } + + for (let ri = 0; ri < nRows; ri++) { + const { key, sym, lo, hi } = rows[ri]; + const y0 = ri * HEATMAP_ROW_H; + + const diffs: number[] = []; + for (let c = 0; c < nDisplay; c++) { + const iA = sampleIdx(tsA.length, c); + const iB = sampleIdx(tsB.length, c); + diffs.push((tsB[iB][key] as number) - (tsA[iA][key] as number)); + } + const maxAbsDiff = Math.max(...diffs.map(Math.abs), 0.001); + + ctx.font = "bold 8px ui-monospace, 'JetBrains Mono', monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = lerpRgba(lo, hi, 0.75, 0.8); + ctx.fillText(sym, HEATMAP_LABEL_W / 2, y0 + HEATMAP_ROW_H / 2); + + for (let c = 0; c < nDisplay; c++) { + const normD = diffs[c] / maxAbsDiff; + ctx.fillStyle = diffColor(normD); + ctx.fillRect(HEATMAP_LABEL_W + c * colW, y0, colW + 0.5, HEATMAP_ROW_H); + } + + if (ri < nRows - 1) { + ctx.fillStyle = "rgba(0,0,0,0.35)"; + ctx.fillRect(HEATMAP_LABEL_W, y0 + HEATMAP_ROW_H - 0.5, plotW, 1); + } + } + + // Legend bar + const legendY = nRows * HEATMAP_ROW_H + 2; + const legendH = 6; + const nStops = 80; + const sw = plotW / nStops; + for (let i = 0; i < nStops; i++) { + const d = (i / (nStops - 1)) * 2 - 1; + ctx.fillStyle = diffColor(d); + ctx.globalAlpha = 1; + ctx.fillRect(HEATMAP_LABEL_W + i * sw, legendY, sw + 0.5, legendH); + } + ctx.globalAlpha = 1; + + ctx.font = "7px ui-sans-serif, system-ui, sans-serif"; + ctx.textBaseline = "top"; + ctx.fillStyle = dark ? "rgba(100,120,200,0.9)" : "rgba(40,80,200,0.9)"; + ctx.textAlign = "left"; + ctx.fillText("A>B", HEATMAP_LABEL_W, legendY + legendH + 1); + ctx.fillStyle = dark ? "rgba(200,70,70,0.9)" : "rgba(180,30,30,0.9)"; + ctx.textAlign = "right"; + ctx.fillText("B>A", cssW, legendY + legendH + 1); +} diff --git a/src/lib/compare/compare-logic.ts b/src/lib/compare/compare-logic.ts new file mode 100644 index 000000000..5e786e546 --- /dev/null +++ b/src/lib/compare/compare-logic.ts @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Compare page business logic — pure functions extracted from +page.svelte. + * + * Timeline helpers, date/time conversions, and range computations. + */ + +import type { EmbeddingSession } from "$lib/compare/compare-types"; +import { + dateToLocalKey, + fmtDateTime, + fmtDateTimeLocalInput, + fmtDayKey, + fmtDuration as fmtDurationSecs, + fromUnix, + parseDateTimeLocalInput, +} from "$lib/format"; + +// ── Date / time helpers ────────────────────────────────────────────────────── + +/** Local date string "YYYY-MM-DD" from a UTC unix-second timestamp. */ +export function localDateFromUtc(utc: number): string { + return dateToLocalKey(fromUnix(utc)); +} + +/** UTC seconds for local midnight of a "YYYY-MM-DD" date string. */ +export function localMidnight(dateStr: string): number { + const [y, mo, d] = dateStr.split("-").map(Number); + return Math.floor(new Date(y, mo - 1, d, 0, 0, 0).getTime() / 1000); +} + +/** "HH:MM" from a UTC unix-second timestamp (local time). */ +export function utcToTimeStr(utc: number): string { + const d = new Date(utc * 1000); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +/** UTC seconds from a "YYYY-MM-DD" date and "HH:MM" time (local). */ +export function timeStrToUtc(dateStr: string, timeStr: string): number { + const [y, mo, d] = dateStr.split("-").map(Number); + const [h, mi] = timeStr.split(":").map(Number); + return Math.floor(new Date(y, mo - 1, d, h, mi, 0).getTime() / 1000); +} + +/** Human-readable date label for a "YYYY-MM-DD" string. */ +export function dayLabel(dateStr: string): string { + return fmtDayKey(dateStr); +} + +/** "YYYY-MM-DDThh:mm:ss" from a UTC unix-second timestamp (for datetime-local inputs). */ +export function utcToDateTimeLocal(utc: number): string { + return fmtDateTimeLocalInput(utc); +} + +/** UTC seconds from a "YYYY-MM-DDThh:mm" datetime-local string. */ +export function dateTimeLocalToUtc(dt: string): number { + return parseDateTimeLocalInput(dt); +} + +// ── Session helpers ────────────────────────────────────────────────────────── + +/** Sessions that overlap the half-open interval [startUtc, endUtc). */ +export function sessionsInRange(sessions: EmbeddingSession[], startUtc: number, endUtc: number): EmbeddingSession[] { + return sessions.filter((s) => s.end_utc > startUtc && s.start_utc < endUtc); +} + +/** Format a session as a human-readable label. */ +export function sessionLabel(s: EmbeddingSession): string { + const dt = fmtDateTime(s.start_utc); + const dur = fmtDurationSecs(s.end_utc - s.start_utc); + return `${dt} (${dur}, ${s.n_epochs} ep)`; +} + +/** Sorted unique day strings (newest first) that have recorded sessions. */ +export function sortedSessionDays(sessions: EmbeddingSession[]): string[] { + const s = new Set(); + for (const sess of sessions) { + const d = new Date( + fromUnix(sess.start_utc).getFullYear(), + fromUnix(sess.start_utc).getMonth(), + fromUnix(sess.start_utc).getDate(), + ); + const endD = fromUnix(sess.end_utc); + const endMid = new Date(endD.getFullYear(), endD.getMonth(), endD.getDate()); + while (d <= endMid) { + s.add(dateToLocalKey(d)); + d.setDate(d.getDate() + 1); + } + } + return [...s].sort().reverse(); // newest first +} + +// ── Range selection helpers ────────────────────────────────────────────────── + +/** Auto-select the range covering all sessions within the 48h window starting at anchorUtc. */ +export function autoSelectRange(sessions: EmbeddingSession[], anchorUtc: number): { start: number; end: number } { + const windowSess = sessions.filter((s) => s.end_utc > anchorUtc && s.start_utc < anchorUtc + 172800); + const rangeS = windowSess.length > 0 ? Math.min(...windowSess.map((s) => s.start_utc)) : anchorUtc; + const rangeE = Math.min( + windowSess.length > 0 ? Math.max(...windowSess.map((s) => s.end_utc)) : anchorUtc + 86400, + rangeS + 86400, + ); + return { start: rangeS, end: rangeE }; +} + +/** Snap a UTC timestamp to the nearest minute. */ +export function snapToMinute(utc: number): number { + return Math.round(utc / 60) * 60; +} + +/** Convert a pointer event's X position within an element to a UTC timestamp in a 48h window. */ +export function pointerToUtc(clientX: number, rect: DOMRect, anchorUtc: number): number { + const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return Math.round(anchorUtc + pct * 172800); +} diff --git a/src/lib/compare/compare-types.ts b/src/lib/compare/compare-types.ts new file mode 100644 index 000000000..b1bfa5ae6 --- /dev/null +++ b/src/lib/compare/compare-types.ts @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Shared type definitions and constants for the compare feature. + * + * Extracted from `routes/compare/+page.svelte`. + */ + +import { BANDS } from "$lib/constants"; +import type { SessionMetrics } from "$lib/dashboard/SessionDetail.svelte"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +/** A contiguous recording range discovered from embedding timestamps. */ +export interface EmbeddingSession { + start_utc: number; + end_utc: number; + n_epochs: number; + day: string; +} + +/** Metric row descriptor for the advanced metrics table. */ +export interface MRow { + key: keyof SessionMetrics; + label: string; + unit: string; + fmt: (v: number) => string; +} + +export interface InsightDelta { + label: string; + key: keyof SessionMetrics; + a: number; + b: number; + delta: number; + pctChange: number; + direction: "improved" | "declined" | "stable"; +} + +export interface ClusterAnalysis { + separationScore: number; + interCluster: number; + intraSpreadA: number; + intraSpreadB: number; +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +export { SESSION_COLORS } from "$lib/constants"; + +export const bandKeys = ["rel_delta", "rel_theta", "rel_alpha", "rel_beta", "rel_gamma"] as const; +export type BK = (typeof bandKeys)[number]; +export const bandMeta = BANDS.slice(0, 5); + +export const scoreKeys = [ + { key: "relaxation" as const, color: "#10b981", label: "dashboard.relaxation" }, + { key: "engagement" as const, color: "#f59e0b", label: "dashboard.engagement" }, +]; + +export const radarMetrics = [ + { key: "relaxation" as const, label: "Relax", max: 100, color: "#10b981" }, + { key: "engagement" as const, label: "Engage", max: 100, color: "#f59e0b" }, + { key: "meditation" as const, label: "Meditation", max: 100, color: "#8b5cf6" }, + { key: "cognitive_load" as const, label: "Cog Load", max: 100, color: "#0ea5e9" }, + { key: "drowsiness" as const, label: "Drowsiness", max: 100, color: "#ef4444" }, +]; + +const fmtF3 = (v: number) => v.toFixed(3); +const fmtF2 = (v: number) => v.toFixed(2); +const fmtF1 = (v: number) => v.toFixed(1); + +export const advancedMetrics: MRow[] = [ + { key: "tar", label: "compare.tar", unit: "", fmt: fmtF3 }, + { key: "bar", label: "compare.bar", unit: "", fmt: fmtF3 }, + { key: "dtr", label: "compare.dtr", unit: "", fmt: fmtF3 }, + { key: "tbr", label: "compare.tbr", unit: "", fmt: fmtF3 }, + { key: "pse", label: "compare.pse", unit: "", fmt: fmtF3 }, + { key: "apf", label: "compare.apf", unit: "Hz", fmt: fmtF2 }, + { key: "sef95", label: "compare.sef95", unit: "Hz", fmt: fmtF2 }, + { key: "spectral_centroid", label: "compare.spectralCentroid", unit: "Hz", fmt: fmtF2 }, + { key: "bps", label: "compare.bps", unit: "", fmt: fmtF3 }, + { key: "snr", label: "compare.snr", unit: "dB", fmt: fmtF1 }, + { key: "coherence", label: "compare.coherence", unit: "", fmt: fmtF3 }, + { key: "mu_suppression", label: "compare.muSuppression", unit: "", fmt: fmtF3 }, + { key: "mood", label: "compare.mood", unit: "", fmt: fmtF1 }, + { key: "hjorth_activity", label: "compare.hjorthActivity", unit: "µV²", fmt: fmtF3 }, + { key: "hjorth_mobility", label: "compare.hjorthMobility", unit: "", fmt: fmtF3 }, + { key: "hjorth_complexity", label: "compare.hjorthComplexity", unit: "", fmt: fmtF3 }, + { key: "permutation_entropy", label: "compare.permEntropy", unit: "", fmt: fmtF3 }, + { key: "higuchi_fd", label: "compare.higuchiFd", unit: "", fmt: fmtF3 }, + { key: "dfa_exponent", label: "compare.dfaExponent", unit: "", fmt: fmtF3 }, + { key: "sample_entropy", label: "compare.sampleEntropy", unit: "", fmt: fmtF3 }, + { key: "pac_theta_gamma", label: "compare.pacThetaGamma", unit: "", fmt: fmtF3 }, + { key: "laterality_index", label: "compare.lateralityIndex", unit: "", fmt: fmtF3 }, + { key: "echt", label: "compare.echt", unit: "", fmt: fmtF3 }, + { key: "hr", label: "compare.hr", unit: "bpm", fmt: fmtF1 }, + { key: "rmssd", label: "compare.rmssd", unit: "ms", fmt: fmtF1 }, + { key: "sdnn", label: "compare.sdnn", unit: "ms", fmt: fmtF1 }, + { key: "pnn50", label: "compare.pnn50", unit: "%", fmt: fmtF1 }, + { key: "lf_hf_ratio", label: "compare.lfHfRatio", unit: "", fmt: fmtF2 }, + { key: "respiratory_rate", label: "compare.respiratoryRate", unit: "bpm", fmt: fmtF1 }, + { key: "spo2_estimate", label: "compare.spo2", unit: "%", fmt: fmtF1 }, + { key: "perfusion_index", label: "compare.perfusionIndex", unit: "%", fmt: fmtF2 }, + { key: "stress_index", label: "compare.stressIndex", unit: "", fmt: fmtF1 }, + { key: "meditation", label: "compare.meditation", unit: "", fmt: fmtF1 }, + { key: "cognitive_load", label: "compare.cognitiveLoad", unit: "", fmt: fmtF1 }, + { key: "drowsiness", label: "compare.drowsiness", unit: "", fmt: fmtF1 }, + { key: "blink_count", label: "compare.blinkCount", unit: "", fmt: fmtF1 }, + { key: "blink_rate", label: "compare.blinkRate", unit: "/min", fmt: fmtF1 }, + { key: "head_pitch", label: "compare.headPitch", unit: "°", fmt: fmtF1 }, + { key: "head_roll", label: "compare.headRoll", unit: "°", fmt: fmtF1 }, + { key: "stillness", label: "compare.stillness", unit: "", fmt: fmtF1 }, + { key: "nod_count", label: "compare.nodCount", unit: "", fmt: fmtF1 }, + { key: "shake_count", label: "compare.shakeCount", unit: "", fmt: fmtF1 }, +]; + +/** Metrics where higher B value = improvement. */ +export const HIGHER_IS_BETTER = new Set([ + "relaxation", + "engagement", + "meditation", + "coherence", + "snr", + "mu_suppression", + "stillness", + "spo2_estimate", + "pnn50", + "rmssd", + "sdnn", + "echt", +]); + +/** Metrics where lower B value = improvement. */ +export const LOWER_IS_BETTER = new Set([ + "stress_index", + "cognitive_load", + "drowsiness", + "blink_rate", + "lf_hf_ratio", +]); + +export const insightMetrics: { key: keyof SessionMetrics; label: string }[] = [ + { key: "relaxation", label: "Relaxation" }, + { key: "engagement", label: "Engagement" }, + { key: "meditation", label: "Meditation" }, + { key: "cognitive_load", label: "Cog. Load" }, + { key: "drowsiness", label: "Drowsiness" }, + { key: "stress_index", label: "Stress" }, + { key: "coherence", label: "Coherence" }, + { key: "snr", label: "SNR" }, + { key: "mu_suppression", label: "μ Suppression" }, + { key: "hr", label: "Heart Rate" }, + { key: "rmssd", label: "RMSSD" }, + { key: "sdnn", label: "SDNN" }, + { key: "pnn50", label: "pNN50" }, + { key: "stillness", label: "Stillness" }, + { key: "blink_rate", label: "Blink Rate" }, + { key: "lf_hf_ratio", label: "LF/HF" }, +]; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +export function bv(m: SessionMetrics | null, k: BK): number { + return m ? (m[k] ?? 0) : 0; +} +export function pct(v: number): string { + return (v * 100).toFixed(1); +} + +export function diff(a: number, b: number): string { + const d = a - b; + if (Math.abs(d) < 0.001) return "—"; + return `${d > 0 ? "+" : ""}${(d * 100).toFixed(1)}`; +} + +export function scoreDiff(a: number, b: number): string { + const d = a - b; + if (Math.abs(d) < 0.1) return "—"; + return `${d > 0 ? "+" : ""}${d.toFixed(1)}`; +} + +export function dc(a: number, b: number): string { + const d = a - b; + if (Math.abs(d) < 0.001) return "text-muted-foreground/40"; + return d > 0 ? "text-emerald-500" : "text-red-400"; +} + +export function sdc(a: number, b: number): string { + const d = a - b; + if (Math.abs(d) < 0.1) return "text-muted-foreground/40"; + return d > 0 ? "text-emerald-500" : "text-red-400"; +} + +// ── UMAP analysis ──────────────────────────────────────────────────────────── + +import type { UmapPoint, UmapResult } from "$lib/types"; +import { gauss } from "$lib/umap/umap-helpers"; + +export function analyzeUmapClusters(result: UmapResult): ClusterAnalysis | null { + const pts = result.points; + if (pts.length < 4) return null; + const ptsA = pts.filter((p) => p.session === 0); + const ptsB = pts.filter((p) => p.session === 1); + if (ptsA.length < 2 || ptsB.length < 2) return null; + + const centroid = (arr: UmapPoint[]) => ({ + x: arr.reduce((s, p) => s + p.x, 0) / arr.length, + y: arr.reduce((s, p) => s + p.y, 0) / arr.length, + z: arr.reduce((s, p) => s + (p.z ?? 0), 0) / arr.length, + }); + const dist = (a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }) => + Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2 + (a.z - b.z) ** 2); + + const cA = centroid(ptsA); + const cB = centroid(ptsB); + const interCluster = dist(cA, cB); + const intraSpreadA = Math.sqrt( + ptsA.reduce((s, p) => s + dist({ x: p.x, y: p.y, z: p.z ?? 0 }, cA) ** 2, 0) / ptsA.length, + ); + const intraSpreadB = Math.sqrt( + ptsB.reduce((s, p) => s + dist({ x: p.x, y: p.y, z: p.z ?? 0 }, cB) ** 2, 0) / ptsB.length, + ); + const avgIntra = (intraSpreadA + intraSpreadB) / 2; + const separationScore = avgIntra > 0 ? interCluster / avgIntra : 0; + + return { separationScore, interCluster, intraSpreadA, intraSpreadB }; +} + +/** Generate a random placeholder UMAP result for loading state. */ +export function generateUmapPlaceholder(nA: number, nB: number): UmapResult { + const points: UmapPoint[] = []; + for (let i = 0; i < nA; i++) { + points.push({ x: -3 + gauss() * 3, y: gauss() * 3, z: gauss() * 3, session: 0, utc: 0 }); + } + for (let i = 0; i < nB; i++) { + points.push({ x: 3 + gauss() * 3, y: gauss() * 3, z: gauss() * 3, session: 1, utc: 0 }); + } + return { points, n_a: nA, n_b: nB, dim: 0 }; +} + +// ── Insight computation ────────────────────────────────────────────────────── + +export function computeInsightDeltas(metricsA: SessionMetrics, metricsB: SessionMetrics): InsightDelta[] { + return insightMetrics.map((m) => { + const a = Number(metricsA[m.key]) || 0; + const b = Number(metricsB[m.key]) || 0; + const delta = b - a; + const pctChange = a !== 0 ? (delta / Math.abs(a)) * 100 : 0; + let direction: "improved" | "declined" | "stable" = "stable"; + if (Math.abs(pctChange) > 3) { + if (HIGHER_IS_BETTER.has(m.key)) direction = delta > 0 ? "improved" : "declined"; + else if (LOWER_IS_BETTER.has(m.key)) direction = delta < 0 ? "improved" : "declined"; + } + return { label: m.label, key: m.key, a, b, delta, pctChange, direction }; + }); +} diff --git a/src/lib/components/BrainStatusBar.svelte b/src/lib/components/BrainStatusBar.svelte new file mode 100644 index 000000000..7aab5380b --- /dev/null +++ b/src/lib/components/BrainStatusBar.svelte @@ -0,0 +1,75 @@ + + + + +
+ + + {#if flow?.in_flow} + + + + + + FLOW {fmtMins(flow.duration_secs)} + + {:else if flow && flow.score > 0} + + focus {flow.score.toFixed(0)} + + {/if} + + + {#if fatigue?.fatigued} + + TIRED {fatigue.continuous_work_mins}m + + {/if} + + + {#if streak && streak.current_streak_days > 0} + + {streak.current_streak_days}d streak + + {/if} + + + {#if flow && flow.edit_velocity > 0} + + {flow.edit_velocity.toFixed(1)} ln/min + + {/if} + + + {#if flow?.avg_focus != null} + + + {flow.avg_focus.toFixed(0)} + + {/if} + + + {#if streak && streak.today_deep_mins > 0} + + {streak.today_deep_mins}m deep + + {/if} +
diff --git a/src/lib/components/DaemonActivityPanel.svelte b/src/lib/components/DaemonActivityPanel.svelte new file mode 100644 index 000000000..48859c7b4 --- /dev/null +++ b/src/lib/components/DaemonActivityPanel.svelte @@ -0,0 +1,168 @@ + + + + + +
+ {t("daemonActivity.title")} + + + {#if loading} +

{t("daemonActivity.loading")}

+ {:else if error} +

{error}

+ {:else} +
    + {#each tasks as task (task.id)} +
  • +
    +
    + {task.name} + {#if task.state?.running} + + + {t("daemonActivity.running")} + + {:else if task.state && !task.state.running} + + {t("daemonActivity.idle")} + + {/if} +
    +
    + {intervalLabel(task.intervalSecs)} + + {costLabel(task.cost)} +
    +
    +

    {task.does}

    +

    + {t("daemonActivity.whyPrefix")} {task.why} +

    + {#if task.state?.detail} +

    {task.state.detail}

    + {/if} +

    + {lastRanLabel(task.heartbeat.lastTickUnixMs)} + {#if task.heartbeat.lastDurationMs > 0} + + {t("daemonActivity.tickDuration", { n: task.heartbeat.lastDurationMs })} + {/if} + {#if task.heartbeat.tickCount > 0} + + {t("daemonActivity.tickCount", { n: task.heartbeat.tickCount })} + {/if} +

    +
  • + {/each} +
+ {/if} +
+
+
diff --git a/src/lib/components/ui/badge/badge.svelte b/src/lib/components/ui/badge/badge.svelte index 70df2612c..85cf91ea2 100644 --- a/src/lib/components/ui/badge/badge.svelte +++ b/src/lib/components/ui/badge/badge.svelte @@ -5,27 +5,25 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. -->
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte index 108767e9c..fb57a9b31 100644 --- a/src/lib/components/ui/card/card-description.svelte +++ b/src/lib/components/ui/card/card-description.svelte @@ -5,15 +5,15 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. -->

+ + + + +
+ {#each items as item} + {@const active = selected === item} + + {/each} +
diff --git a/src/lib/components/ui/chip-group/index.ts b/src/lib/components/ui/chip-group/index.ts new file mode 100644 index 000000000..b37cc0366 --- /dev/null +++ b/src/lib/components/ui/chip-group/index.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +import ChipGroup from "./ChipGroup.svelte"; + +export { ChipGroup }; diff --git a/src/lib/components/ui/confirm-action/ConfirmAction.svelte b/src/lib/components/ui/confirm-action/ConfirmAction.svelte new file mode 100644 index 000000000..73530a661 --- /dev/null +++ b/src/lib/components/ui/confirm-action/ConfirmAction.svelte @@ -0,0 +1,52 @@ + + + + +
+ + {message} + + + + +
diff --git a/src/lib/components/ui/confirm-action/index.ts b/src/lib/components/ui/confirm-action/index.ts new file mode 100644 index 000000000..2f3836679 --- /dev/null +++ b/src/lib/components/ui/confirm-action/index.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: GPL-3.0-only +export { default as ConfirmAction } from "./ConfirmAction.svelte"; diff --git a/src/lib/components/ui/dialog/dialog-close.svelte b/src/lib/components/ui/dialog/dialog-close.svelte index 4b77ed95f..09d610b51 100644 --- a/src/lib/components/ui/dialog/dialog-close.svelte +++ b/src/lib/components/ui/dialog/dialog-close.svelte @@ -5,9 +5,9 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte index 48cbcd006..cfc03a7d6 100644 --- a/src/lib/components/ui/dialog/dialog-content.svelte +++ b/src/lib/components/ui/dialog/dialog-content.svelte @@ -5,25 +5,24 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> diff --git a/src/lib/components/ui/dialog/dialog-description.svelte b/src/lib/components/ui/dialog/dialog-description.svelte index 5259c1fe8..eee24ffb4 100644 --- a/src/lib/components/ui/dialog/dialog-description.svelte +++ b/src/lib/components/ui/dialog/dialog-description.svelte @@ -5,14 +5,10 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. -->
diff --git a/src/lib/components/ui/dialog/dialog-title.svelte b/src/lib/components/ui/dialog/dialog-title.svelte index fb68d16ec..64335b659 100644 --- a/src/lib/components/ui/dialog/dialog-title.svelte +++ b/src/lib/components/ui/dialog/dialog-title.svelte @@ -5,14 +5,10 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> diff --git a/src/lib/components/ui/dialog/dialog.svelte b/src/lib/components/ui/dialog/dialog.svelte index bb6168403..0bdb78bc9 100644 --- a/src/lib/components/ui/dialog/dialog.svelte +++ b/src/lib/components/ui/dialog/dialog.svelte @@ -5,9 +5,9 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> diff --git a/src/lib/components/ui/dialog/index.ts b/src/lib/components/ui/dialog/index.ts index 15751ce1e..f5101c6bf 100644 --- a/src/lib/components/ui/dialog/index.ts +++ b/src/lib/components/ui/dialog/index.ts @@ -4,13 +4,13 @@ // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, version 3 only. -export { default as Root } from "./dialog.svelte"; -export { default as Content } from "./dialog-content.svelte"; -export { default as Close } from "./dialog-close.svelte"; +export { default as Root } from "./dialog.svelte"; +export { default as Close } from "./dialog-close.svelte"; +export { default as Content } from "./dialog-content.svelte"; export { default as Description } from "./dialog-description.svelte"; -export { default as Footer } from "./dialog-footer.svelte"; -export { default as Header } from "./dialog-header.svelte"; -export { default as Overlay } from "./dialog-overlay.svelte"; -export { default as Portal } from "./dialog-portal.svelte"; -export { default as Title } from "./dialog-title.svelte"; -export { default as Trigger } from "./dialog-trigger.svelte"; +export { default as Footer } from "./dialog-footer.svelte"; +export { default as Header } from "./dialog-header.svelte"; +export { default as Overlay } from "./dialog-overlay.svelte"; +export { default as Portal } from "./dialog-portal.svelte"; +export { default as Title } from "./dialog-title.svelte"; +export { default as Trigger } from "./dialog-trigger.svelte"; diff --git a/src/lib/components/ui/form-row/FormRow.svelte b/src/lib/components/ui/form-row/FormRow.svelte new file mode 100644 index 000000000..8d315a91e --- /dev/null +++ b/src/lib/components/ui/form-row/FormRow.svelte @@ -0,0 +1,48 @@ + + + + + +
+
+ {label} + {#if value} + {value} + {/if} +
+ {#if description} +

+ {description} +

+ {/if} + {#if children} + {@render children()} + {/if} +
diff --git a/src/lib/components/ui/form-row/index.ts b/src/lib/components/ui/form-row/index.ts new file mode 100644 index 000000000..75f8d42e3 --- /dev/null +++ b/src/lib/components/ui/form-row/index.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +import FormRow from "./FormRow.svelte"; + +export { FormRow }; diff --git a/src/lib/components/ui/icons/IconCheck.svelte b/src/lib/components/ui/icons/IconCheck.svelte new file mode 100644 index 000000000..19c15735c --- /dev/null +++ b/src/lib/components/ui/icons/IconCheck.svelte @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/lib/components/ui/icons/IconChevronRight.svelte b/src/lib/components/ui/icons/IconChevronRight.svelte new file mode 100644 index 000000000..de2ad96eb --- /dev/null +++ b/src/lib/components/ui/icons/IconChevronRight.svelte @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/lib/components/ui/icons/IconExternalLink.svelte b/src/lib/components/ui/icons/IconExternalLink.svelte new file mode 100644 index 000000000..03e6baf34 --- /dev/null +++ b/src/lib/components/ui/icons/IconExternalLink.svelte @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/src/lib/components/ui/icons/IconWarning.svelte b/src/lib/components/ui/icons/IconWarning.svelte new file mode 100644 index 000000000..de6e993d1 --- /dev/null +++ b/src/lib/components/ui/icons/IconWarning.svelte @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/lib/components/ui/icons/IconX.svelte b/src/lib/components/ui/icons/IconX.svelte new file mode 100644 index 000000000..e87b6b36f --- /dev/null +++ b/src/lib/components/ui/icons/IconX.svelte @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/lib/components/ui/icons/index.ts b/src/lib/components/ui/icons/index.ts new file mode 100644 index 000000000..58ed9aa37 --- /dev/null +++ b/src/lib/components/ui/icons/index.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +export { default as IconCheck } from "./IconCheck.svelte"; +export { default as IconChevronRight } from "./IconChevronRight.svelte"; +export { default as IconExternalLink } from "./IconExternalLink.svelte"; +export { default as IconWarning } from "./IconWarning.svelte"; +export { default as IconX } from "./IconX.svelte"; diff --git a/src/lib/components/ui/progress/index.ts b/src/lib/components/ui/progress/index.ts index aba8ca76b..b45c1e8dd 100644 --- a/src/lib/components/ui/progress/index.ts +++ b/src/lib/components/ui/progress/index.ts @@ -7,7 +7,7 @@ import Root from "./progress.svelte"; export { - Root, - // - Root as Progress, + Root, + // + Root as Progress, }; diff --git a/src/lib/components/ui/progress/progress.svelte b/src/lib/components/ui/progress/progress.svelte index c99073c16..17ce7cc54 100644 --- a/src/lib/components/ui/progress/progress.svelte +++ b/src/lib/components/ui/progress/progress.svelte @@ -5,16 +5,16 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> + + + + + {@render children?.()} + diff --git a/src/lib/components/ui/section-header/index.ts b/src/lib/components/ui/section-header/index.ts new file mode 100644 index 000000000..a914dd1a6 --- /dev/null +++ b/src/lib/components/ui/section-header/index.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +import SectionHeader from "./SectionHeader.svelte"; + +export { SectionHeader }; diff --git a/src/lib/components/ui/separator/index.ts b/src/lib/components/ui/separator/index.ts index 88c1bc11f..d3a4fc78f 100644 --- a/src/lib/components/ui/separator/index.ts +++ b/src/lib/components/ui/separator/index.ts @@ -7,7 +7,7 @@ import Root from "./separator.svelte"; export { - Root, - // - Root as Separator, + Root, + // + Root as Separator, }; diff --git a/src/lib/components/ui/separator/separator.svelte b/src/lib/components/ui/separator/separator.svelte index 3c75ba8e2..e5a1adc88 100644 --- a/src/lib/components/ui/separator/separator.svelte +++ b/src/lib/components/ui/separator/separator.svelte @@ -5,15 +5,15 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> + + + + + + {@render children?.()} + diff --git a/src/lib/components/ui/settings-card/index.ts b/src/lib/components/ui/settings-card/index.ts new file mode 100644 index 000000000..7c623f39d --- /dev/null +++ b/src/lib/components/ui/settings-card/index.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +import SettingsCard from "./SettingsCard.svelte"; + +export { SettingsCard }; diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts index 6e570330a..6616daa94 100644 --- a/src/lib/components/ui/skeleton/index.ts +++ b/src/lib/components/ui/skeleton/index.ts @@ -7,7 +7,7 @@ import Root from "./skeleton.svelte"; export { - Root, - // - Root as Skeleton, + Root, + // + Root as Skeleton, }; diff --git a/src/lib/components/ui/skeleton/skeleton.svelte b/src/lib/components/ui/skeleton/skeleton.svelte index ee0f73ab1..4619de3ad 100644 --- a/src/lib/components/ui/skeleton/skeleton.svelte +++ b/src/lib/components/ui/skeleton/skeleton.svelte @@ -5,14 +5,14 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. -->
+ + + + + + + + + {label} + diff --git a/src/lib/components/ui/status-badge/index.ts b/src/lib/components/ui/status-badge/index.ts new file mode 100644 index 000000000..7cddfd9cc --- /dev/null +++ b/src/lib/components/ui/status-badge/index.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +import StatusBadge, { type StatusLevel } from "./StatusBadge.svelte"; + +export { StatusBadge, type StatusLevel }; diff --git a/src/lib/components/ui/textarea/index.ts b/src/lib/components/ui/textarea/index.ts index 4d83b432a..bdd7cb3e0 100644 --- a/src/lib/components/ui/textarea/index.ts +++ b/src/lib/components/ui/textarea/index.ts @@ -6,7 +6,4 @@ // the Free Software Foundation, version 3 only. import Root from "./textarea.svelte"; -export { - Root, - Root as Textarea, -}; +export { Root, Root as Textarea }; diff --git a/src/lib/components/ui/textarea/textarea.svelte b/src/lib/components/ui/textarea/textarea.svelte index 8278a8abd..e32e8cd96 100644 --- a/src/lib/components/ui/textarea/textarea.svelte +++ b/src/lib/components/ui/textarea/textarea.svelte @@ -5,16 +5,16 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> +
+ +
+ {/if} +
+ + +
+ +
+ + + + + {/if} + + + {#if isNewEngine} +
+ {engineLabel} + + + + + {#if hasModelPicker} +
+ {t("chat.tts.modelLabel")} + +
+ {/if} + + {#if hasVoicePicker} +
+ {t("chat.tts.voiceLabel")} + +
+ {:else} +

{t("chat.tts.voiceDesc")}

+ {/if} + +
+
+
+ {/if} + + +
+ {t("ttsTab.testSection")} +

+ {t("ttsTab.testDesc")} +

+ +
+ + +
+ {t("ttsTab.apiSection")} +

+ {t("ttsTab.apiDesc")} +

+
+ {#each [ + ["WebSocket", `{"command":"say","text":"Eyes closed. Relax."}`], + ["HTTP (curl)", `curl -X POST http://localhost:/say \\ + -H 'Content-Type: application/json' \\ + -d '{"text":"Eyes closed. Relax."}'`], + ["websocat (CLI)", `echo '{"command":"say","text":"Eyes closed."}' | websocat ws://localhost:`], + ] as [label, code]} +
+ + {label} + +
{code}
+
+ {/each} +
+
+ + +
+ {t("ttsTab.startupSection")} + + + { + ttsPreload = !ttsPreload; + daemonInvoke("set_tts_preload", { preload: ttsPreload }).catch(e => console.warn("[tts] set_tts_preload failed:", e)); + }} + + showBadge={false} + /> + +
+ + +
+ {t("ttsTab.loggingSection")} + + + + +
+ +
diff --git a/src/lib/settings/UmapTab.svelte b/src/lib/settings/UmapTab.svelte new file mode 100644 index 000000000..19ddd00b8 --- /dev/null +++ b/src/lib/settings/UmapTab.svelte @@ -0,0 +1,452 @@ + + + + + +{#if !loaded} +
+ {t("common.loading")} +
+{:else} + + +{#if availableBackends.length > 0} +
+
+ {t("umapSettings.backend")} + {#if saving} + {t("common.saving")} + {/if} + {#if dirty} + + {/if} +
+ + + +

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

+ [b.toUpperCase(), b] as [string, string]), + ]} + selected={ + [["Auto", "auto"] as [string, string], + ...availableBackends.map(b => [b.toUpperCase(), b] as [string, string]), + ].find(p => p[1] === cfg.backend) ?? ["Auto", "auto"] + } + onselect={(p) => { cfg.backend = p[1]; markDirty(); }} + labelFn={(p) => p[0]} + /> +
+ + {@const activePrecisions = precisionsByBackend[cfg.backend === "auto" ? (availableBackends[0] ?? "gpu") : cfg.backend] ?? ["f32"]} + +
+ {t("umapSettings.precision")} + {cfg.precision.toUpperCase()} +
+

+ {t("umapSettings.precisionDesc")} +

+ [p.toUpperCase(), p] as [string, string])} + selected={activePrecisions.map(p => [p.toUpperCase(), p] as [string, string]).find(p => p[1] === cfg.precision) ?? [activePrecisions[0].toUpperCase(), activePrecisions[0]]} + onselect={(p) => { cfg.precision = p[1]; markDirty(); }} + labelFn={(p) => p[0]} + /> +
+
+
+{/if} + + +
+
+ {t("umapSettings.repulsion")} + {#if saving} + {t("common.saving")} + {/if} + {#if dirty} + + {/if} +
+ + + + + +
+
+ {t("umapSettings.repulsionStrength")} + {cfg.repulsion_strength.toFixed(1)} +
+

+ {t("umapSettings.repulsionDesc")} +

+ p[1] === cfg.repulsion_strength) ?? REPULSION_PRESETS[0]} + onselect={(p) => { cfg.repulsion_strength = p[1]; markDirty(); }} + labelFn={(p) => p[0]} + /> + +
+ + +
+
+ {t("umapSettings.negSampleRate")} + {cfg.neg_sample_rate} +
+

+ {t("umapSettings.negSampleRateDesc")} +

+ p[1] === cfg.neg_sample_rate) ?? NEG_SAMPLE_PRESETS[0]} + onselect={(p) => { cfg.neg_sample_rate = p[1]; markDirty(); }} + labelFn={(p) => p[0]} + /> +
+
+
+
+ + +
+ {t("umapSettings.graphOptimisation")} + + + + + +
+
+ {t("umapSettings.nNeighbors")} + {cfg.n_neighbors} +
+

+ {t("umapSettings.nNeighborsDesc")} +

+ p[1] === cfg.n_neighbors) ?? NEIGHBOR_PRESETS[0]} + onselect={(p) => { cfg.n_neighbors = p[1]; markDirty(); }} + labelFn={(p) => p[0]} + /> +
+ + +
+
+ {t("umapSettings.nEpochs")} + {cfg.n_epochs} +
+

+ {t("umapSettings.nEpochsDesc")} +

+ p[1] === cfg.n_epochs) ?? EPOCH_PRESETS[0]} + onselect={(p) => { cfg.n_epochs = p[1]; markDirty(); }} + labelFn={(p) => p[0]} + /> +
+
+
+
+ + +
+ {t("umapSettings.safetyPerformance")} + + + + + +
+
+ {t("umapSettings.timeout")} + {cfg.timeout_secs}s +
+

+ {t("umapSettings.timeoutDesc")} +

+ p[1] === cfg.timeout_secs) ?? TIMEOUT_PRESETS[0]} + onselect={(p) => { cfg.timeout_secs = p[1]; markDirty(); }} + labelFn={(p) => p[0]} + /> +
+ + +
+
+ {t("umapSettings.cooldownMs")} + + {cfg.cooldown_ms === 0 ? "0 ms" : cfg.cooldown_ms < 1000 ? `${cfg.cooldown_ms} ms` : `${(cfg.cooldown_ms / 1000).toFixed(1)}s`} + +
+

+ {t("umapSettings.cooldownMsDesc")} +

+ +
+ 0s5s10s +
+
+ + +
+ {t("umapSettings.pipeline")} + + repulsion {cfg.repulsion_strength.toFixed(1)} + + + neg×{cfg.neg_sample_rate} + + + k={cfg.n_neighbors} + + + {cfg.n_epochs} epochs + + + {cfg.timeout_secs}s timeout + + + {cfg.cooldown_ms}ms cooldown + + + {cfg.backend === "auto" ? "auto" : cfg.backend.toUpperCase()} · {cfg.precision} + + fast-umap 1.6.0 +
+
+
+
+ + +
+ + {#if dirty} + + {/if} +
+ +{/if} + + diff --git a/src/lib/settings/UpdatesTab.svelte b/src/lib/settings/UpdatesTab.svelte new file mode 100644 index 000000000..7047860e3 --- /dev/null +++ b/src/lib/settings/UpdatesTab.svelte @@ -0,0 +1,716 @@ + + + + + +
+ + +
+
+ +
+
+ {t("updates.title")} + + {t("updates.currentVersion", { version: appVersion })} + +
+ + {#if phase === "ready"} + + ✅ {t("updates.readyToRestart")} + + {:else if phase === "downloading"} + {progress}% + {:else if phase === "checking"} + + + + {/if} +
+ + + + + +
+ + {#if phase === "ready"} + +
+
+ ✅ +
+
+ + {t("updates.installed", { version: available?.version ?? "" })} + + + {t("updates.restartWhenReady")} + +
+
+ + +
+
+ + {#if sessionBlockWarning} +
+ + + {t("updates.sessionLiveBlocked")} + +
+ {/if} + + {:else if phase === "downloading"} + +
+
+ + {t("updates.downloading", { version: available?.version ?? "" })} + + + {progress}% + +
+
+
+
+ {#if available?.body} +

{available.body}

+ {/if} +
+ + {:else} + +
+ {#if phase === "error" && available} + +
+ ⚠ +
+
+ + v{available.version} {t("updates.available")} + + + {t("updates.downloadFailed")} + +
+ {:else if phase === "idle" && available && !autoUpdateEnabled} + +
+ ⬆ +
+
+ + v{available.version} {t("updates.available")} + + + {t("updates.autoUpdateOffNotice")} + +
+ + {:else} +
+ + {phase === "checking" ? t("updates.checking") : t("updates.upToDate")} + + + {t("updates.lastChecked")}: {fmtLastChecked()} + +
+ {/if} + + + {#if !(phase === "idle" && available && !autoUpdateEnabled)} + + {/if} +
+ + + {#if phase === "error" && error} +
+ {error} +
+ {/if} + + {#if phase === "error" && available} +
+ +
+ {/if} + {/if} + +
+
+
+ + + + +
+
+
+ + {t("updates.checkInterval")} + + + {t("updates.checkIntervalDesc")} + +
+ {#if intervalSaving} + + + + {/if} +
+ + p[0] === checkIntervalSecs) ?? INTERVAL_OPTIONS[0]} + onselect={(p) => setCheckInterval(p[0])} + labelFn={(p) => t(p[1])} + /> + + {#if checkIntervalSecs === 0} +

+ {t("updates.intervalOffWarning")} +

+ {/if} +
+
+
+ + + + + + + {#if autoUpdateError} +
+ {autoUpdateError} +
+ {/if} +
+
+ + + + + + + {#if autostartError} +
+ {autostartError} +
+ {/if} +
+
+ + + + + + + {#if channelError} +
+ {channelError} +
+ {/if} +
+
+ + +
+ + {t("updates.footer")} + +
+ +
diff --git a/src/lib/settings/ValidationTab.svelte b/src/lib/settings/ValidationTab.svelte new file mode 100644 index 000000000..f488fd4df --- /dev/null +++ b/src/lib/settings/ValidationTab.svelte @@ -0,0 +1,545 @@ + + + + + +
+ + + + +
+ + {t("validation.disclaimer")} +
+ + {#if loadError} +
+ Failed to load validation config: {loadError} +
+ {/if} + + {#if config} + + + +

{t("validation.master.title")}

+ + + +
+ + +
+

{t("validation.master.quietDesc")}

+
+
+ + + + +
+

{t("validation.kss.title")}

+

{t("validation.kss.desc")}

+
+ + + + {#if config.kss.enabled} +
+
+ + +
+ + + + + {#if config.kss.trigger_random} + + {/if} +
+ {/if} +
+
+ + + + +
+

{t("validation.tlx.title")}

+

{t("validation.tlx.desc")}

+
+ + + + {#if config.tlx.enabled} +
+
+ + +
+ +
+ +
+
+ {/if} +
+
+ + + + +
+

{t("validation.pvt.title")}

+

{t("validation.pvt.desc")}

+
+ + + + {#if config.pvt.enabled} +
+ +
+ {/if} + +
+ +
+
+
+ + + + +
+

{t("validation.eeg.title")}

+

{t("validation.eeg.desc")}

+
+ + + + {#if config.eeg_fatigue.enabled} +
+ +
+ {t("validation.eeg.current")} +
+ {fatigueIndex !== null ? fatigueIndex.toFixed(2) : t("validation.eeg.noHeadset")} +
+
+
+ {/if} +
+
+ + + + +
+

{t("validation.calibrationWeek.title")}

+

{t("validation.calibrationWeek.desc")}

+
+
+ +
+
+
+ + + + +

{t("validation.results.title")}

+ {#if recentKssCount > 0} +

+ {t("validation.results.kssCount", { 0: recentKssCount })} +

+ {:else} +

{t("validation.results.empty")}

+ {/if} +
+
+ + + {#if saveStatus !== "idle"} +
+ {#if saveStatus === "saving"}{t("validation.save.saving")}{/if} + {#if saveStatus === "saved"}{t("validation.save.saved")}{/if} + {#if saveStatus === "error"}{t("validation.save.failed", { 0: saveError })}{/if} +
+ {/if} + {/if} +
+ +{#if pvtOpen && config} + (pvtOpen = false)} /> +{/if} + +{#if tlxOpen && config} + (tlxOpen = false)} taskKind="manual" /> +{/if} diff --git a/src/lib/settings/VirtualEegTab.svelte b/src/lib/settings/VirtualEegTab.svelte new file mode 100644 index 000000000..f8b5cd25c --- /dev/null +++ b/src/lib/settings/VirtualEegTab.svelte @@ -0,0 +1,571 @@ + + + + + +
+ + +
+

{t("veeg.title")}

+

{t("veeg.desc")}

+
+ + + + +
+ + {#if running} + + + {:else} + + {/if} + + + {running ? t("veeg.running") : t("veeg.stopped")} + + {#if running} + + {config.channels}ch · {config.sampleRate} Hz + + {/if} +
+ +
+
+ + +
+ {t("veeg.template")} + + + {#each TEMPLATES as tmpl} + + {/each} + + {#if config.template === "file"} +
+ + + {config.fileName ?? t("veeg.noFile")} + +
+ {/if} +
+
+
+ + +
+ {t("veeg.channels")} + + +
+ {t("veeg.channelsDesc")} +
+ {#each CHANNEL_OPTIONS as n} + + {/each} +
+ {#if config.channels > 0} + + {channelLabels.join(", ")} + + {/if} +
+ + + +
+ {t("veeg.sampleRateDesc")} +
+ {#each RATE_OPTIONS as hz} + + {/each} +
+
+
+
+
+ + +
+ {t("veeg.quality")} + + + {t("veeg.qualityDesc")} +
+ {#each QUALITY_OPTIONS as q} + + {/each} +
+
+
+
+ + +
+ + + {#if showAdvanced} + + + + +
+
+ {t("veeg.amplitudeUv")} + {t("veeg.amplitudeDesc")} +
+ +
+ + + + +
+
+ {t("veeg.noiseUv")} + {t("veeg.noiseDesc")} +
+ +
+ + + + +
+
+ {t("veeg.lineNoise")} + {t("veeg.lineNoiseDesc")} +
+
+ {#each [["none", "veeg.lineNoiseNone"], ["50hz", "veeg.lineNoise50"], ["60hz", "veeg.lineNoise60"]] as [val, label]} + + {/each} +
+
+ + + + +
+
+ {t("veeg.dropoutProb")} + {t("veeg.dropoutDesc")} +
+ +
+ +
+
+ {/if} +
+ + +
+ {t("veeg.preview")} + + + +

{t("veeg.previewDesc")}

+
+
+
+ +
diff --git a/src/lib/settings/devices-logic.ts b/src/lib/settings/devices-logic.ts new file mode 100644 index 000000000..dcc9b4d91 --- /dev/null +++ b/src/lib/settings/devices-logic.ts @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * DevicesTab pure logic — extracted from DevicesTab.svelte. + * + * Fuzzy matching, device image resolution, channel labeling, and formatting. + */ + +// ── Virtual device detection ──────────────────────────────────────────────────── + +/** + * Return true when `dev` is a simulated/virtual device rather than real hardware. + * + * Detection rules (any match → virtual): + * - name contains "virtual" (case-insensitive) — covers "SkillVirtualEEG", + * "Virtual EEG", etc. + * - id contains "virtual" (case-insensitive) — covers daemon id "virtual-eeg" + */ +export function isVirtualDevice(dev: { id: string; name: string }): boolean { + const n = dev.name.toLowerCase(); + const id = dev.id.toLowerCase(); + return n.includes("virtual") || id.includes("virtual"); +} + +/** + * Stable-sort a device list so real hardware always appears above virtual + * devices. Within each group the original relative order is preserved. + */ +export function sortDevicesRealFirst(devs: T[]): T[] { + return [...devs].sort((a, b) => { + const av = isVirtualDevice(a) ? 1 : 0; + const bv = isVirtualDevice(b) ? 1 : 0; + return av - bv; // real (0) before virtual (1) + }); +} + +// ── Fuzzy search ────────────────────────────────────────────────────────────── + +/** Fuzzy substring + character-sequence match. */ +export function fuzzyMatch(haystack: string, needle: string): boolean { + if (!needle) return true; + const h = haystack.toLowerCase(); + const n = needle.toLowerCase(); + if (h.includes(n)) return true; + let hIdx = 0; + for (let i = 0; i < n.length; i++) { + hIdx = h.indexOf(n[i], hIdx); + if (hIdx === -1) return false; + hIdx++; + } + return true; +} + +// ── Device image resolution ─────────────────────────────────────────────────── + +/** Resolve the product image path for a Muse-family device. */ +export function museImage(name: string, _hw?: string | null): string | null { + const n = name.toLowerCase(); + // Athena (Muse S gen 2) advertises as "MuseS-XXXX" (no space before S). + // hardware_version is not set by the Muse adapter so we rely on the name only. + const isAthena = n.includes("muses"); + if (isAthena) return "/devices/muse-s-athena.jpg"; + if (n.includes("muse-s") || n.includes("muse s")) return "/devices/muse-s-gen1.jpg"; + if (n.includes("muse-2") || n.includes("muse2") || n.includes("muse 2")) return "/devices/muse-gen2.jpg"; + if (n.includes("muse")) return "/devices/muse-gen1.jpg"; + if (n.includes("mw75") || n.includes("neurable")) return "/devices/muse-mw75.jpg"; + return null; +} + +/** Resolve the product image path for any supported device. */ +export function deviceImage(name: string, hw?: string | null): string | null { + const muse = museImage(name, hw); + if (muse) return muse; + + const n = name.toLowerCase(); + if (n.includes("antneuro") || n.includes("eego")) return "/devices/antneuro-eego-mylab.webp"; + if (n.includes("idun") || n.includes("guardian") || n.startsWith("ige")) return "/devices/idun-guardian.png"; + if (n.includes("insight")) return "/devices/emotiv-insight.webp"; + if (n.includes("flex")) return "/devices/emotiv-flex-saline.webp"; + if (n.includes("mn8")) return "/devices/emotiv-mn8.webp"; + if (n.includes("epoc")) return "/devices/emotiv-epoc-x.webp"; + if (n.includes("ganglion")) return "/devices/openbci-ganglion.jpg"; + if (n.includes("cyton")) return "/devices/openbci-cyton.jpg"; + if (n.includes("hermes")) return "/devices/hermes.jpg"; + if (n.includes("mendi")) return "/devices/mendi-headband.png"; + if (n.includes("awear") || n.includes("luca")) return "/devices/awear-eeg.png"; + if (n.includes("atu") || n.includes("attentivu")) return "/devices/attentivu-glasses.png"; + if (n.includes("quick-32") || n.includes("q32")) return "/devices/cgx-quick-32r.png"; + if (n.includes("quick-20r-v1") || n.includes("q20r-v1")) return "/devices/cgx-quick-20r-v1.png"; + if (n.includes("quick-20") || n.includes("q20") || n.includes("cognionics") || n.includes("cgx")) + return "/devices/cgx-quick-20r.png"; + if (n.includes("quick-8") || n.includes("q8")) return "/devices/cgx-quick-8r.png"; + if (n.includes("aim-2") || n.includes("aim2")) return "/devices/cgx-aim-2.png"; + return null; +} + +// ── OpenBCI channel labels ──────────────────────────────────────────────────── + +const OPENBCI_DEFAULT_LABELS = ["Fp1", "Fp2", "C3", "C4", "T5", "T6", "O1", "O2"]; + +/** Return the default 10-20 label for an OpenBCI channel index. */ +export function openbciChannelLabel(i: number): string { + return OPENBCI_DEFAULT_LABELS[i] ?? `Ch${i + 1}`; +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +/** Format a Unix timestamp as a relative "last seen" string. */ +export function fmtLastSeen(ts: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - ts; + if (diff < 60) return "just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +// ── Types for merge/preferred helpers ───────────────────────────────────────── + +export interface DeviceBase { + id: string; + name: string; + last_seen: number; + last_rssi: number; + is_paired: boolean; + is_preferred: boolean; + transport?: string; +} + +export interface PairedInfo { + id: string; + name: string; + last_seen?: number; +} + +// ── Merge & preferred helpers ───────────────────────────────────────────────── + +/** + * Infer transport type from device ID prefix. + */ +function inferTransport(id: string): "ble" | "usb_serial" | "wifi" | undefined { + if (id.startsWith("ble:")) return "ble"; + if (id.startsWith("usb:")) return "usb_serial"; + if (id.startsWith("wifi:") || (id.includes(":") && id.includes("."))) return "wifi"; + return undefined; +} + +/** + * Merge paired devices from status into a base device list. + * Devices already in `base` are preserved as-is (including is_preferred). + * Devices in `paired` but missing from `base` are added with is_preferred=false. + */ +export function mergePairedIntoDevices(base: T[], paired: PairedInfo[]): T[] { + const out = [...base]; + const byId = new Set(out.map((d) => d.id)); + for (const p of paired) { + if (!byId.has(p.id)) { + out.push({ + id: p.id, + name: p.name, + last_seen: p.last_seen ?? 0, + last_rssi: 0, + is_paired: true, + is_preferred: false, + transport: inferTransport(p.id), + } as T); + byId.add(p.id); + } + } + return out; +} + +/** + * Apply a preferred device selection: sets is_preferred=true on the device + * matching `targetId`, and is_preferred=false on all others. + * If `targetId` is empty, clears all preferred flags. + */ +export function applyPreferred(devices: T[], targetId: string): T[] { + return devices.map((d) => ({ ...d, is_preferred: targetId !== "" && d.id === targetId })); +} diff --git a/src/lib/settings/goals-logic.ts b/src/lib/settings/goals-logic.ts new file mode 100644 index 000000000..34de47443 --- /dev/null +++ b/src/lib/settings/goals-logic.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * GoalsTab pure logic — extracted from GoalsTab.svelte. + * + * Progress bar coloring and time formatting for the daily goal tracker. + */ + +/** Color for the daily goal progress bar based on minutes recorded. */ +export function barColor(mins: number, goalMin: number): string { + if (mins >= goalMin) return "#22c55e"; // green — goal met + if (mins >= goalMin * 0.5) return "#3b82f6"; // blue — halfway+ + if (mins === 0) return "transparent"; + return "#6366f1"; // indigo — some progress +} + +/** Format minutes as "1h 23m" or "45m" or "—" for zero. */ +export function fmtMins(m: number): string { + if (m === 0) return "\u2014"; + if (m < 60) return `${m}m`; + const remainder = m % 60; + return `${Math.floor(m / 60)}h${remainder > 0 ? ` ${remainder}m` : ""}`; +} diff --git a/src/lib/settings/hooks-logic.ts b/src/lib/settings/hooks-logic.ts new file mode 100644 index 000000000..5b6c1f8b0 --- /dev/null +++ b/src/lib/settings/hooks-logic.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * HooksTab pure logic — extracted from HooksTab.svelte. + * + * Timestamp conversion and relative-time formatting for the hooks log. + */ + +/** + * Convert a UTC microsecond timestamp to Unix seconds. + * Returns 0 for invalid input. + */ +export function tsToUnix(tsUtc: number): number { + if (!tsUtc || Number.isNaN(tsUtc)) return 0; + // Detect microsecond vs second timestamps + return tsUtc > 1e15 ? Math.floor(tsUtc / 1_000_000) : tsUtc > 1e12 ? Math.floor(tsUtc / 1000) : Math.floor(tsUtc); +} + +/** + * Format a relative age string from a UTC timestamp. + * @param tsUtc - timestamp (microseconds, milliseconds, or seconds) + * @param nowSecs - current time in Unix seconds + * @param agoLabel - localized "ago" suffix + */ +export function relativeAge(tsUtc: number, nowSecs: number, agoLabel = "ago"): string { + const unix = tsToUnix(tsUtc); + if (!unix || Number.isNaN(unix)) return ""; + const diff = nowSecs - unix; + if (diff < 0) return ""; + if (diff < 60) return `${diff}s ${agoLabel}`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ${agoLabel}`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ${agoLabel}`; + return `${Math.floor(diff / 86400)}d ${agoLabel}`; +} diff --git a/src/lib/settings/onboarding-logic.ts b/src/lib/settings/onboarding-logic.ts new file mode 100644 index 000000000..4edb19553 --- /dev/null +++ b/src/lib/settings/onboarding-logic.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Onboarding page business logic — extracted from routes/onboarding/+page.svelte. + * + * Pure functions for model selection during the onboarding flow. + */ + +export interface LlmModelEntry { + family_id: string; + family_name: string; + quant: string; + is_mmproj: boolean; + recommended: boolean; + state: string; + size_gb: number; +} + +/** + * Pick the best model within a given family by quantization preference. + * + * Priority: Q4_K_M > Q8_0 > Q4_0 > any Q4* > recommended > downloaded > first. + */ +export function pickFamilyTarget(entries: LlmModelEntry[], familyId: string, familyRe: RegExp): LlmModelEntry | null { + const family = entries.filter((e) => !e.is_mmproj && (e.family_id === familyId || familyRe.test(e.family_name))); + if (!family.length) return null; + const byQuant = (q: string) => family.find((e) => e.quant.toUpperCase() === q); + return ( + byQuant("Q4_K_M") ?? + byQuant("Q8_0") ?? + byQuant("Q4_0") ?? + family.find((e) => e.quant.toUpperCase().startsWith("Q4")) ?? + family.find((e) => e.recommended) ?? + family.find((e) => e.state === "downloaded") ?? + family[0] + ); +} + +/** + * Pick the default LLM to download during onboarding. + * + * Priority: + * 1. Already-downloaded model (any family) — skip download. + * 2. LFM2.5 1.2B Instruct — default bootstrap family. + * 3. Any recommended model, smallest first. + */ +export function pickLlmTarget(entries: LlmModelEntry[]): LlmModelEntry | null { + const downloaded = entries.find((e) => !e.is_mmproj && e.state === "downloaded"); + if (downloaded) return downloaded; + + return ( + pickFamilyTarget(entries, "lfm25-1.2b-instruct", /lfm2\.5\s*1\.2b.*instruct/i) ?? + entries.filter((e) => !e.is_mmproj && e.recommended).sort((a, b) => a.size_gb - b.size_gb)[0] ?? + null + ); +} diff --git a/src/lib/settings/pvt-stats.ts b/src/lib/settings/pvt-stats.ts new file mode 100644 index 000000000..e2ba65bd5 --- /dev/null +++ b/src/lib/settings/pvt-stats.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +/** + * Pure helpers for the Psychomotor Vigilance Task (PVT) — extracted from + * PvtPanel.svelte so the statistics can be unit-tested without pulling in + * a Svelte renderer or the daemon HTTP layer. + * + * RT inputs are millisecond reaction times. Lapse threshold follows + * Dinges & Powell (1985) at 500 ms. `slowest10_rt_ms` is the mean of the + * worst 10 % of trials, which is the published anticipation-resistant + * summary statistic — slightly more robust than overall mean RT to a few + * outliers. + */ + +export interface PvtStats { + mean_rt_ms: number; + median_rt_ms: number; + slowest10_rt_ms: number; + lapse_count: number; + response_count: number; +} + +export const LAPSE_THRESHOLD_MS = 500; + +/** Arithmetic mean. Returns 0 for an empty input. */ +export function mean(xs: readonly number[]): number { + if (xs.length === 0) return 0; + let s = 0; + for (const x of xs) s += x; + return s / xs.length; +} + +/** + * Median of an unsorted array — picks the middle value (or the upper + * middle for even-length inputs, matching the Svelte component's + * implementation). Returns 0 for an empty input. + */ +export function median(xs: readonly number[]): number { + if (xs.length === 0) return 0; + const sorted = [...xs].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +/** + * Mean of the slowest 10 % of trials. When there are fewer than 10 + * trials the slowest-bucket size rounds down to zero, in which case we + * fall back to the slowest single trial — that's the convention the + * literature uses for very short PVTs. Returns 0 for an empty input. + */ +export function slowest10Mean(xs: readonly number[]): number { + if (xs.length === 0) return 0; + const sorted = [...xs].sort((a, b) => a - b); + const cut = Math.floor(sorted.length * 0.9); + const tail = sorted.slice(cut); + if (tail.length === 0) return sorted[sorted.length - 1]; + return mean(tail); +} + +/** Trials with RT above the lapse threshold (Dinges & Powell 1985 = 500 ms). */ +export function lapseCount(xs: readonly number[], threshold: number = LAPSE_THRESHOLD_MS): number { + let n = 0; + for (const x of xs) if (x > threshold) n += 1; + return n; +} + +/** Convenience: compute every summary statistic in one pass. */ +export function computeStats(rts: readonly number[]): PvtStats { + return { + mean_rt_ms: mean(rts), + median_rt_ms: median(rts), + slowest10_rt_ms: slowest10Mean(rts), + lapse_count: lapseCount(rts), + response_count: rts.length, + }; +} diff --git a/src/lib/settings/sleep-analysis.ts b/src/lib/settings/sleep-analysis.ts new file mode 100644 index 000000000..47fcad84a --- /dev/null +++ b/src/lib/settings/sleep-analysis.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Shared sleep-analysis helpers. + +import type { SleepStages } from "$lib/types"; + +export interface SleepAnalysis { + efficiency: number; // % of non-wake time + onsetLatencyMin: number; // minutes to first non-wake + remLatencyMin: number; // minutes to first REM (-1 if none) + totalMin: number; // total recording minutes + awakenings: number; // transitions from sleep → wake + stageMinutes: { + wake: number; + n1: number; + n2: number; + n3: number; + rem: number; + }; +} + +/** Derive sleep-analysis metrics from staged epochs. */ +export function analyzeSleep(sleep: SleepStages): SleepAnalysis { + const eps = sleep.epochs; + const epochSecs = sleep.summary.epoch_secs || 5; + const totalMin = (eps.length * epochSecs) / 60; + const wakeMin = (sleep.summary.wake_epochs * epochSecs) / 60; + const efficiency = totalMin > 0 ? ((totalMin - wakeMin) / totalMin) * 100 : 0; + + // Onset latency: time until first non-wake epoch + const onsetIdx = eps.findIndex((e) => e.stage !== 0); + const onsetLatencyMin = onsetIdx >= 0 ? (onsetIdx * epochSecs) / 60 : totalMin; + + // REM latency: time from sleep onset to first REM + const remIdx = eps.findIndex((e) => e.stage === 4); + const remLatencyMin = remIdx >= 0 && onsetIdx >= 0 ? ((remIdx - onsetIdx) * epochSecs) / 60 : -1; + + // Awakenings: transitions from sleep (stage 1-4) to wake (stage 0) + let awakenings = 0; + for (let i = 1; i < eps.length; i++) { + if (eps[i].stage === 0 && eps[i - 1].stage > 0) awakenings++; + } + + const m = (n: number) => (n * epochSecs) / 60; + const stageMinutes = { + wake: m(sleep.summary.wake_epochs), + n1: m(sleep.summary.n1_epochs), + n2: m(sleep.summary.n2_epochs), + n3: m(sleep.summary.n3_epochs), + rem: m(sleep.summary.rem_epochs), + }; + + return { efficiency, onsetLatencyMin, remLatencyMin, totalMin, awakenings, stageMinutes }; +} diff --git a/src/lib/app-name-store.svelte.ts b/src/lib/stores/app-name.svelte.ts similarity index 66% rename from src/lib/app-name-store.svelte.ts rename to src/lib/stores/app-name.svelte.ts index 16ee520e3..ca5a7244b 100644 --- a/src/lib/app-name-store.svelte.ts +++ b/src/lib/stores/app-name.svelte.ts @@ -15,7 +15,10 @@ import { invoke } from "@tauri-apps/api/core"; -let appName = $state("NeuroSkill™"); // sensible fallback while we fetch +const DEFAULT_APP_BASE_NAME = "NeuroSkill"; +const TRADEMARK_SUFFIX = "™"; + +let appName = $state(`${DEFAULT_APP_BASE_NAME}${TRADEMARK_SUFFIX}`); // sensible fallback while we fetch /** Reactive getter — use inside Svelte `$derived` / templates. */ export function getAppName(): string { @@ -27,11 +30,21 @@ function titleCase(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } +/** Ensure UI-facing name always renders with a single trailing trademark sign. */ +function toDisplayName(raw: string): string { + const normalizedBase = raw + .replaceAll(TRADEMARK_SUFFIX, "") + .replace(/\(tm\)/gi, "") + .trim(); + const base = normalizedBase || DEFAULT_APP_BASE_NAME; + return `${titleCase(base)}${TRADEMARK_SUFFIX}`; +} + // Fetch from Rust on module init (runs once per window). (async () => { try { const raw = await invoke("get_app_name"); - if (raw) appName = titleCase(raw); + if (raw) appName = toDisplayName(raw); } catch { // keep fallback } diff --git a/src/lib/stores/brain.svelte.ts b/src/lib/stores/brain.svelte.ts new file mode 100644 index 000000000..c5d7c1e4d --- /dev/null +++ b/src/lib/stores/brain.svelte.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-only +/** + * Reactive brain state store — polls daemon `/v1/brain/*` endpoints every 30s. + * Shared across all components: status bar, dashboard, activity tab, tray. + */ + +import { daemonGet, daemonPost } from "$lib/daemon/http"; + +export interface FlowState { + in_flow: boolean; + score: number; + duration_secs: number; + avg_focus: number | null; + file_switches: number; + edit_velocity: number; +} + +export interface FatigueState { + fatigued: boolean; + focus_decline_pct: number; + continuous_work_mins: number; + suggestion: string; +} + +export interface StreakState { + current_streak_days: number; + today_deep_mins: number; + today_qualifies: boolean; +} + +// ── Reactive state ─────────────────────────────────────────────────────────── + +let flow = $state(null); +let fatigue = $state(null); +let streak = $state(null); +let polling = false; +let timer: ReturnType | undefined; + +export function getFlow(): FlowState | null { + return flow; +} +export function getFatigue(): FatigueState | null { + return fatigue; +} +export function getStreak(): StreakState | null { + return streak; +} + +async function poll(): Promise { + try { + const [f, a, s] = await Promise.allSettled([ + daemonPost("/v1/brain/flow-state", { windowSecs: 300 }), + daemonGet("/v1/brain/fatigue"), + daemonPost("/v1/brain/streak", { minDeepWorkMins: 60 }), + ]); + if (f.status === "fulfilled") flow = f.value; + if (a.status === "fulfilled") fatigue = a.value; + if (s.status === "fulfilled") streak = s.value; + } catch { + // daemon offline + } +} + +let refCount = 0; + +export function startBrainPolling(): void { + refCount++; + if (polling) return; + polling = true; + poll(); // immediate first poll + timer = setInterval(poll, 30_000); +} + +export function stopBrainPolling(): void { + refCount = Math.max(0, refCount - 1); + if (refCount > 0) return; // other consumers still need it + polling = false; + if (timer) { + clearInterval(timer); + timer = undefined; + } +} diff --git a/src/lib/stores/bt-status.svelte.ts b/src/lib/stores/bt-status.svelte.ts new file mode 100644 index 000000000..49046b184 --- /dev/null +++ b/src/lib/stores/bt-status.svelte.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +/** + * Centralised reactive store for Bluetooth availability status. + * + * Set by the main dashboard page when the BLE adapter state changes; + * read by CustomTitleBar to tint the title bar red when BT is off. + */ + +let btOff = $state(false); + +/** Reactive getter — true when Bluetooth is unavailable. */ +export function isBtOff(): boolean { + return btOff; +} + +/** Update the Bluetooth-off flag from the dashboard. */ +export function setBtOff(off: boolean): void { + btOff = off; +} diff --git a/src/lib/chart-colors-store.svelte.ts b/src/lib/stores/chart-colors.svelte.ts similarity index 79% rename from src/lib/chart-colors-store.svelte.ts rename to src/lib/stores/chart-colors.svelte.ts index 4498ad301..2835d65e3 100644 --- a/src/lib/chart-colors-store.svelte.ts +++ b/src/lib/stores/chart-colors.svelte.ts @@ -37,7 +37,7 @@ export interface ChartScheme { delta: string; theta: string; alpha: string; - beta: string; + beta: string; gamma: string; } @@ -46,11 +46,15 @@ export interface ChartScheme { * Green / Blue / Purple / Orange channels with vivid band colors. */ const DEFAULT: ChartScheme = { - id: "default", + id: "default", labelKey: "chartScheme.default", - descKey: "chartScheme.defaultDesc", + descKey: "chartScheme.defaultDesc", channels: ["#22c55e", "#60a5fa", "#c084fc", "#fb923c"], - delta: "#6366f1", theta: "#22c55e", alpha: "#3b82f6", beta: "#f59e0b", gamma: "#ef4444", + delta: "#6366f1", + theta: "#22c55e", + alpha: "#3b82f6", + beta: "#f59e0b", + gamma: "#ef4444", }; /** @@ -58,11 +62,15 @@ const DEFAULT: ChartScheme = { * Cyan / Magenta / Lime / Gold channels. */ const NEON: ChartScheme = { - id: "neon", + id: "neon", labelKey: "chartScheme.neon", - descKey: "chartScheme.neonDesc", + descKey: "chartScheme.neonDesc", channels: ["#00fff7", "#ff00e5", "#b5ff00", "#ffd000"], - delta: "#7c3aed", theta: "#00fff7", alpha: "#3b82f6", beta: "#ffd000", gamma: "#ff006a", + delta: "#7c3aed", + theta: "#00fff7", + alpha: "#3b82f6", + beta: "#ffd000", + gamma: "#ff006a", }; /** @@ -70,11 +78,15 @@ const NEON: ChartScheme = { * White-to-gray channel differentiation for minimal distraction. */ const MONO: ChartScheme = { - id: "mono", + id: "mono", labelKey: "chartScheme.mono", - descKey: "chartScheme.monoDesc", + descKey: "chartScheme.monoDesc", channels: ["#e2e8f0", "#94a3b8", "#cbd5e1", "#64748b"], - delta: "#94a3b8", theta: "#a1a1aa", alpha: "#d4d4d8", beta: "#71717a", gamma: "#52525b", + delta: "#94a3b8", + theta: "#a1a1aa", + alpha: "#d4d4d8", + beta: "#71717a", + gamma: "#52525b", }; /** @@ -83,11 +95,15 @@ const MONO: ChartScheme = { * Based on the Wong (2011) colorblind-safe palette. */ const CB_DEUTAN: ChartScheme = { - id: "cb-deutan", + id: "cb-deutan", labelKey: "chartScheme.cbDeutan", - descKey: "chartScheme.cbDeutanDesc", + descKey: "chartScheme.cbDeutanDesc", channels: ["#0072b2", "#e69f00", "#f0e442", "#cc79a7"], - delta: "#0072b2", theta: "#e69f00", alpha: "#56b4e9", beta: "#f0e442", gamma: "#cc79a7", + delta: "#0072b2", + theta: "#e69f00", + alpha: "#56b4e9", + beta: "#f0e442", + gamma: "#cc79a7", }; /** @@ -95,11 +111,15 @@ const CB_DEUTAN: ChartScheme = { * Uses blue / yellow / teal / pink. */ const CB_PROTAN: ChartScheme = { - id: "cb-protan", + id: "cb-protan", labelKey: "chartScheme.cbProtan", - descKey: "chartScheme.cbProtanDesc", + descKey: "chartScheme.cbProtanDesc", channels: ["#2196f3", "#ffeb3b", "#009688", "#e91e63"], - delta: "#2196f3", theta: "#009688", alpha: "#03a9f4", beta: "#ffeb3b", gamma: "#e91e63", + delta: "#2196f3", + theta: "#009688", + alpha: "#03a9f4", + beta: "#ffeb3b", + gamma: "#e91e63", }; /** @@ -107,23 +127,20 @@ const CB_PROTAN: ChartScheme = { * Uses red / green / magenta / cyan. */ const CB_TRITAN: ChartScheme = { - id: "cb-tritan", + id: "cb-tritan", labelKey: "chartScheme.cbTritan", - descKey: "chartScheme.cbTritanDesc", + descKey: "chartScheme.cbTritanDesc", channels: ["#d32f2f", "#388e3c", "#e040fb", "#00bcd4"], - delta: "#d32f2f", theta: "#388e3c", alpha: "#e040fb", beta: "#00bcd4", gamma: "#ff7043", + delta: "#d32f2f", + theta: "#388e3c", + alpha: "#e040fb", + beta: "#00bcd4", + gamma: "#ff7043", }; // ── All schemes in display order ────────────────────────────────────────────── -export const CHART_SCHEMES: ChartScheme[] = [ - DEFAULT, - NEON, - MONO, - CB_DEUTAN, - CB_PROTAN, - CB_TRITAN, -]; +export const CHART_SCHEMES: ChartScheme[] = [DEFAULT, NEON, MONO, CB_DEUTAN, CB_PROTAN, CB_TRITAN]; // ── Reactive state ──────────────────────────────────────────────────────────── @@ -135,7 +152,7 @@ function load(): string { } function findScheme(id: string): ChartScheme { - return CHART_SCHEMES.find(s => s.id === id) ?? DEFAULT; + return CHART_SCHEMES.find((s) => s.id === id) ?? DEFAULT; } function apply() { @@ -158,9 +175,13 @@ function apply() { // ── Public API ──────────────────────────────────────────────────────────────── -export function getChartScheme(): string { return current; } +export function getChartScheme(): string { + return current; +} -export function getActiveScheme(): ChartScheme { return findScheme(current); } +export function getActiveScheme(): ChartScheme { + return findScheme(current); +} export function setChartScheme(id: string) { current = id; diff --git a/src/lib/stores/cmdk-history.svelte.ts b/src/lib/stores/cmdk-history.svelte.ts new file mode 100644 index 000000000..8c7207aa5 --- /dev/null +++ b/src/lib/stores/cmdk-history.svelte.ts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Cmd-K usage history — tracks which commands users run most often / recently. + * Persisted to localStorage so recency/frequency boosts survive reloads. + */ + +const STORAGE_KEY = "cmdk-history"; +const MAX_ENTRIES = 500; + +interface UsageEntry { + count: number; + lastUsed: number; +} + +let history = $state>(load()); + +function load(): Record { + if (typeof localStorage === "undefined") return {}; + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); + } catch { + return {}; + } +} + +function save() { + if (typeof localStorage === "undefined") return; + // LRU eviction if too large + const entries = Object.entries(history); + if (entries.length > MAX_ENTRIES) { + entries.sort((a, b) => b[1].lastUsed - a[1].lastUsed); + history = Object.fromEntries(entries.slice(0, MAX_ENTRIES)); + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); +} + +export function recordUsage(id: string) { + const now = Date.now(); + const prev = history[id]; + history[id] = { count: (prev?.count ?? 0) + 1, lastUsed: now }; + save(); +} + +export function getUsageStats(): Record { + return history; +} + +/** + * Compute a boost score for a command based on usage frequency and recency. + * Returns 0 for never-used commands. + */ +export function usageBoost(id: string): number { + const entry = history[id]; + if (!entry) return 0; + const now = Date.now(); + const recency = Math.max(0, 1 - (now - entry.lastUsed) / (7 * 86_400_000)); // decays over 7 days + return Math.log2(entry.count + 1) * 3 + recency * 5; +} + +/** + * Get the N most recently used command IDs, sorted by last use time. + */ +export function getRecentIds(n = 5): string[] { + return Object.entries(history) + .sort((a, b) => b[1].lastUsed - a[1].lastUsed) + .slice(0, n) + .map(([id]) => id); +} diff --git a/src/lib/font-size-store.svelte.ts b/src/lib/stores/font-size.svelte.ts similarity index 82% rename from src/lib/font-size-store.svelte.ts rename to src/lib/stores/font-size.svelte.ts index 21dbe668d..c7dcc616f 100644 --- a/src/lib/font-size-store.svelte.ts +++ b/src/lib/stores/font-size.svelte.ts @@ -15,12 +15,14 @@ const STORAGE_KEY = "skill-font-size"; /** Available presets (percentage of base 16px). */ export const FONT_SIZE_PRESETS = [ - { label: "XS", value: 75 }, - { label: "S", value: 87 }, - { label: "M", value: 100 }, - { label: "L", value: 112 }, - { label: "XL", value: 125 }, + { label: "2XS", value: 50 }, + { label: "XS", value: 75 }, + { label: "S", value: 87 }, + { label: "M", value: 100 }, + { label: "L", value: 112 }, + { label: "XL", value: 125 }, { label: "XXL", value: 150 }, + { label: "3XL", value: 200 }, ] as const; export type FontSizeValue = (typeof FONT_SIZE_PRESETS)[number]["value"]; @@ -32,7 +34,7 @@ function load(): number { const v = localStorage.getItem(STORAGE_KEY); if (v) { const n = parseInt(v, 10); - if (FONT_SIZE_PRESETS.some(p => p.value === n)) return n; + if (FONT_SIZE_PRESETS.some((p) => p.value === n)) return n; } return 100; } @@ -46,7 +48,9 @@ function apply() { document.documentElement.style.setProperty("--font-scale", String(pct / 100)); } -export function getFontSize(): number { return current; } +export function getFontSize(): number { + return current; +} export function setFontSize(pct: number) { current = pct; diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts new file mode 100644 index 000000000..7d74a90bd --- /dev/null +++ b/src/lib/stores/theme.svelte.ts @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +/** + * Theme store — manages light / dark / system / high-contrast preference. + * Persists to settings.json via Tauri IPC (with localStorage fallback). + * Applies the "dark" and "high-contrast" classes on reactively. + */ + +import { invoke } from "@tauri-apps/api/core"; + +export type ThemeMode = "system" | "light" | "dark"; + +// ── Accent colour presets ───────────────────────────────────────────────────── +// +// Each preset carries a full `--color-violet-*` palette. At runtime we +// override Tailwind CSS custom properties on so accent-like utility +// classes across the app (`violet-*`, `blue-*`, `indigo-*`, `sky-*`) all +// adopt the selected hue — no template-wide refactor required. +// +// Values are taken directly from Tailwind v4's built-in color palette so +// each preset is a perfectly balanced, perceptually-uniform ramp. + +export interface AccentPreset { + id: string; + label: string; + /** oklch of the 600-shade — shown as the swatch colour in the picker. */ + swatch: string; + palette: Record; +} + +export const ACCENT_PRESETS: AccentPreset[] = [ + { + id: "violet", + label: "Violet", + swatch: "oklch(0.558 0.288 302.3)", + palette: { + "--color-violet-50": "oklch(0.969 0.016 272.3)", + "--color-violet-100": "oklch(0.943 0.029 294.6)", + "--color-violet-200": "oklch(0.895 0.058 296.8)", + "--color-violet-300": "oklch(0.842 0.117 293.1)", + "--color-violet-400": "oklch(0.749 0.194 293.2)", + "--color-violet-500": "oklch(0.627 0.265 292.7)", + "--color-violet-600": "oklch(0.558 0.288 302.3)", + "--color-violet-700": "oklch(0.491 0.270 292.0)", + "--color-violet-800": "oklch(0.432 0.232 292.0)", + "--color-violet-900": "oklch(0.380 0.189 293.0)", + "--color-violet-950": "oklch(0.283 0.141 291.1)", + }, + }, + { + id: "indigo", + label: "Indigo", + swatch: "oklch(0.511 0.262 276.9)", + palette: { + "--color-violet-50": "oklch(0.962 0.018 272.3)", + "--color-violet-100": "oklch(0.930 0.034 272.7)", + "--color-violet-200": "oklch(0.870 0.065 274.0)", + "--color-violet-300": "oklch(0.785 0.115 274.7)", + "--color-violet-400": "oklch(0.673 0.182 276.9)", + "--color-violet-500": "oklch(0.585 0.233 277.1)", + "--color-violet-600": "oklch(0.511 0.262 276.9)", + "--color-violet-700": "oklch(0.457 0.240 277.0)", + "--color-violet-800": "oklch(0.398 0.195 277.7)", + "--color-violet-900": "oklch(0.359 0.144 278.7)", + "--color-violet-950": "oklch(0.257 0.090 281.3)", + }, + }, + { + id: "blue", + label: "Blue", + swatch: "oklch(0.546 0.245 262.9)", + palette: { + "--color-violet-50": "oklch(0.970 0.014 254.6)", + "--color-violet-100": "oklch(0.932 0.032 255.6)", + "--color-violet-200": "oklch(0.882 0.059 254.1)", + "--color-violet-300": "oklch(0.809 0.105 251.8)", + "--color-violet-400": "oklch(0.707 0.165 254.6)", + "--color-violet-500": "oklch(0.623 0.214 259.1)", + "--color-violet-600": "oklch(0.546 0.245 262.9)", + "--color-violet-700": "oklch(0.488 0.243 264.4)", + "--color-violet-800": "oklch(0.424 0.199 265.6)", + "--color-violet-900": "oklch(0.379 0.146 265.1)", + "--color-violet-950": "oklch(0.282 0.091 267.9)", + }, + }, + { + id: "sky", + label: "Sky", + swatch: "oklch(0.588 0.158 241.9)", + palette: { + "--color-violet-50": "oklch(0.977 0.013 236.6)", + "--color-violet-100": "oklch(0.951 0.026 236.6)", + "--color-violet-200": "oklch(0.901 0.058 230.9)", + "--color-violet-300": "oklch(0.828 0.111 230.3)", + "--color-violet-400": "oklch(0.746 0.161 225.9)", + "--color-violet-500": "oklch(0.685 0.169 237.3)", + "--color-violet-600": "oklch(0.588 0.158 241.9)", + "--color-violet-700": "oklch(0.500 0.134 242.7)", + "--color-violet-800": "oklch(0.443 0.110 240.8)", + "--color-violet-900": "oklch(0.391 0.090 240.4)", + "--color-violet-950": "oklch(0.293 0.066 243.2)", + }, + }, + { + id: "teal", + label: "Teal", + swatch: "oklch(0.600 0.145 184.7)", + palette: { + "--color-violet-50": "oklch(0.984 0.014 180.7)", + "--color-violet-100": "oklch(0.963 0.023 180.8)", + "--color-violet-200": "oklch(0.910 0.048 180.4)", + "--color-violet-300": "oklch(0.855 0.099 180.4)", + "--color-violet-400": "oklch(0.777 0.152 180.6)", + "--color-violet-500": "oklch(0.705 0.157 187.3)", + "--color-violet-600": "oklch(0.600 0.145 184.7)", + "--color-violet-700": "oklch(0.510 0.122 184.8)", + "--color-violet-800": "oklch(0.442 0.095 185.9)", + "--color-violet-900": "oklch(0.391 0.077 188.7)", + "--color-violet-950": "oklch(0.277 0.056 192.6)", + }, + }, + { + id: "emerald", + label: "Emerald", + swatch: "oklch(0.596 0.145 163.0)", + palette: { + "--color-violet-50": "oklch(0.979 0.021 166.1)", + "--color-violet-100": "oklch(0.950 0.052 163.1)", + "--color-violet-200": "oklch(0.905 0.093 164.1)", + "--color-violet-300": "oklch(0.845 0.143 164.8)", + "--color-violet-400": "oklch(0.765 0.177 163.2)", + "--color-violet-500": "oklch(0.696 0.170 162.5)", + "--color-violet-600": "oklch(0.596 0.145 163.0)", + "--color-violet-700": "oklch(0.508 0.118 165.6)", + "--color-violet-800": "oklch(0.432 0.095 166.7)", + "--color-violet-900": "oklch(0.378 0.077 168.1)", + "--color-violet-950": "oklch(0.262 0.051 172.6)", + }, + }, + { + id: "rose", + label: "Rose", + swatch: "oklch(0.586 0.253 17.6)", + palette: { + "--color-violet-50": "oklch(0.969 0.015 12.4)", + "--color-violet-100": "oklch(0.941 0.030 12.6)", + "--color-violet-200": "oklch(0.892 0.058 10.0)", + "--color-violet-300": "oklch(0.811 0.111 8.6)", + "--color-violet-400": "oklch(0.712 0.194 13.4)", + "--color-violet-500": "oklch(0.645 0.246 16.4)", + "--color-violet-600": "oklch(0.586 0.253 17.6)", + "--color-violet-700": "oklch(0.514 0.222 16.9)", + "--color-violet-800": "oklch(0.455 0.188 13.1)", + "--color-violet-900": "oklch(0.408 0.153 2.4)", + "--color-violet-950": "oklch(0.271 0.105 12.8)", + }, + }, + { + id: "pink", + label: "Pink", + swatch: "oklch(0.592 0.249 0.6)", + palette: { + "--color-violet-50": "oklch(0.971 0.014 343.2)", + "--color-violet-100": "oklch(0.948 0.028 342.3)", + "--color-violet-200": "oklch(0.899 0.061 343.2)", + "--color-violet-300": "oklch(0.823 0.120 343.4)", + "--color-violet-400": "oklch(0.718 0.202 349.8)", + "--color-violet-500": "oklch(0.656 0.241 355.0)", + "--color-violet-600": "oklch(0.592 0.249 0.6)", + "--color-violet-700": "oklch(0.525 0.223 3.9)", + "--color-violet-800": "oklch(0.459 0.187 3.8)", + "--color-violet-900": "oklch(0.408 0.153 2.4)", + "--color-violet-950": "oklch(0.271 0.105 12.8)", + }, + }, +]; + +const STORAGE_KEY = "skill-theme"; +const HC_KEY = "skill-high-contrast"; +const ACCENT_KEY = "skill-accent"; + +let mode = $state(loadMode()); +let resolved = $state<"light" | "dark">(resolve(loadMode())); +let highContrast = $state(loadHC()); +let accentId = $state(loadAccentId()); + +function loadMode(): ThemeMode { + if (typeof localStorage === "undefined") return "system"; + const v = localStorage.getItem(STORAGE_KEY); + if (v === "light" || v === "dark") return v; + return "system"; +} + +function loadAccentId(): string { + if (typeof localStorage === "undefined") return "violet"; + return localStorage.getItem(ACCENT_KEY) ?? "violet"; +} + +/** Load persisted theme + accent from Tauri settings on startup. */ +export async function initFromSettings() { + try { + const [theme, _lang] = await invoke<[string, string]>("get_theme_and_language"); + if (theme === "light" || theme === "dark" || theme === "system") { + setTheme(theme as ThemeMode); + } + } catch { + /* not available (e.g. dev server without Tauri) */ + } + try { + const id = await invoke("get_accent_color"); + applyAccent(id); + } catch { + /* degrade gracefully */ + } +} + +function loadHC(): boolean { + if (typeof localStorage === "undefined") return false; + return localStorage.getItem(HC_KEY) === "true"; +} + +function resolve(m: ThemeMode): "light" | "dark" { + if (m !== "system") return m; + if (typeof window === "undefined") return "dark"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function apply() { + if (typeof document === "undefined") return; + resolved = resolve(mode); + document.documentElement.classList.toggle("dark", resolved === "dark"); + document.documentElement.classList.toggle("high-contrast", highContrast); +} + +/** Listen for OS theme changes (only matters in "system" mode). */ +if (typeof window !== "undefined") { + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { + if (mode === "system") apply(); + }); + // Also respond to OS high-contrast / forced-colors preference. + window.matchMedia("(prefers-contrast: more)").addEventListener("change", (e) => { + if (!localStorage.getItem(HC_KEY)) { + highContrast = e.matches; + apply(); + } + }); +} + +export function getTheme(): ThemeMode { + return mode; +} +export function getResolved(): "light" | "dark" { + return resolved; +} +export function getHighContrast(): boolean { + return highContrast; +} +export function getAccentId(): string { + return accentId; +} + +// ── Accent application ──────────────────────────────────────────────────────── + +/** + * Override the Tailwind `--color-violet-*` CSS custom properties on + * so every `violet-*` utility class adopts the new accent hue instantly. + * Falls back to the "violet" preset if `id` is unknown. + */ +export function applyAccent(id: string) { + const preset = ACCENT_PRESETS.find((p) => p.id === id) ?? ACCENT_PRESETS[0]; + accentId = preset.id; + if (typeof localStorage !== "undefined") { + if (preset.id === "violet") localStorage.removeItem(ACCENT_KEY); + else localStorage.setItem(ACCENT_KEY, preset.id); + } + if (typeof document === "undefined") return; + const style = document.documentElement.style; + const accentFamilies = ["violet", "blue", "indigo", "sky"] as const; + for (const [prop, val] of Object.entries(preset.palette)) { + style.setProperty(prop, val); + for (const family of accentFamilies) { + if (family === "violet") continue; + style.setProperty(prop.replace("--color-violet-", `--color-${family}-`), val); + } + } +} + +export function setAccent(id: string) { + applyAccent(id); + invoke("set_accent_color", { accent: id }).catch((_e) => {}); +} + +export function setTheme(m: ThemeMode) { + mode = m; + if (typeof localStorage !== "undefined") { + if (m === "system") localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, m); + } + apply(); + // Persist to settings.json via Tauri + invoke("set_theme", { theme: m }).catch((_e) => {}); +} + +export function setHighContrast(on: boolean) { + highContrast = on; + if (typeof localStorage !== "undefined") { + if (on) localStorage.setItem(HC_KEY, "true"); + else localStorage.removeItem(HC_KEY); + } + apply(); +} + +export function toggleHighContrast() { + setHighContrast(!highContrast); +} + +/** Toggle between light and dark (ignores system — just flips the resolved value). */ +export function toggleTheme() { + setTheme(resolved === "dark" ? "light" : "dark"); +} + +/** Cycle: system → light → dark → system */ +export function cycleTheme() { + const next: Record = { + system: "light", + light: "dark", + dark: "system", + }; + setTheme(next[mode]); +} + +// Apply on load +apply(); +applyAccent(loadAccentId()); diff --git a/src/lib/stores/titlebar.svelte.ts b/src/lib/stores/titlebar.svelte.ts new file mode 100644 index 000000000..3262377c3 --- /dev/null +++ b/src/lib/stores/titlebar.svelte.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 only. +/** + * Titlebar reactive state — shared between page components (writers) and + * CustomTitleBar.svelte (reader). + * + * Consolidated from the former per-page titlebar files: + * titlebar-state.svelte.ts, chat-titlebar.svelte.ts, + * history-titlebar.svelte.ts, label-titlebar.svelte.ts, + * help-search-state.svelte.ts + */ + +// ── Factory helpers ─────────────────────────────────────────────────────────── + +/** + * Create a module-level `$state` store with a typed initial value. + * + * ```ts + * export const chatTitlebar = createTitlebarState({ modelName: "", status: "stopped" as const }); + * // page writes: chatTitlebar.modelName = "phi-3"; + * // titlebar reads: {chatTitlebar.modelName} + * ``` + */ +export function createTitlebarState>(initial: T): T { + const s: T = $state(initial) as T; + return s; +} + +/** + * Create a callback bag initialised with no-op functions. + * + * ```ts + * export const hCbs = createTitlebarCallbacks({ prev: () => {}, next: () => {} }); + * // page sets: hCbs.prev = () => navigatePrev(); + * // titlebar calls: hCbs.prev(); + * ``` + */ +// biome-ignore lint/suspicious/noExplicitAny: generic callback map — args are opaque +export function createTitlebarCallbacks void>>(defaults: T): T { + // Plain object — intentionally not reactive. The titlebar calls these + // synchronously; Svelte reactivity is not needed on the callback bag itself. + return { ...defaults }; +} + +// ── Chat titlebar ───────────────────────────────────────────────────────────── + +export type LlmStatus = "stopped" | "loading" | "running"; + +export const chatTitlebarState = createTitlebarState<{ + modelName: string; + status: LlmStatus; +}>({ modelName: "", status: "stopped" }); + +// ── History titlebar ────────────────────────────────────────────────────────── + +/** Calendar heatmap view granularity. */ +export type HistoryViewMode = "year" | "month" | "week" | "day"; + +/** Display data written by the history page, read by CustomTitleBar. */ +export const hBar = createTitlebarState({ + /** True once the history page has mounted and set up callbacks. */ + active: false, + /** Mirrors history page `daysLoading`. */ + daysLoading: true, + /** Total number of local calendar days with recordings. */ + dayCount: 0, + /** Index of the currently displayed day (0 = newest). */ + currentDayIdx: 0, + /** Pre-formatted label for the current day (e.g. "Mar 12, 2026"). */ + currentDayLabel: "", + /** Whether compare mode is active. */ + compareMode: false, + /** Number of sessions selected for comparison. */ + compareCount: 0, + /** Whether the labels panel is open. */ + showLabels: false, + /** Current history view mode (calendar granularity). */ + viewMode: "month" as HistoryViewMode, + /** Navigation label for calendar views (e.g. "March 2026"). */ + calendarLabel: "", +}); + +/** Callbacks set by the history page on mount. */ +export const hCbs = createTitlebarCallbacks({ + prev: () => {}, + next: () => {}, + toggleCompare: () => {}, + openCompare: () => {}, + toggleLabels: () => {}, + reload: () => {}, + setViewMode: (_m: HistoryViewMode) => {}, + calendarPrev: () => {}, + calendarNext: () => {}, +}); + +// ── Label titlebar ──────────────────────────────────────────────────────────── + +export const labelTitlebarState = createTitlebarState({ active: false, elapsed: "0s" }); + +// ── Help titlebar ───────────────────────────────────────────────────────────── + +export const helpTitlebarState = $state({ query: "", version: "…" }); diff --git a/src/lib/toast-store.svelte.ts b/src/lib/stores/toast.svelte.ts similarity index 90% rename from src/lib/toast-store.svelte.ts rename to src/lib/stores/toast.svelte.ts index 68e2a8288..52db8fdf4 100644 --- a/src/lib/toast-store.svelte.ts +++ b/src/lib/stores/toast.svelte.ts @@ -29,21 +29,16 @@ let nextId = 0; export const toasts = $state([]); const DEFAULT_DURATIONS: Record = { - info: 5_000, + info: 5_000, success: 4_000, warning: 6_000, - error: 8_000, + error: 8_000, }; /** * Add a toast notification. Returns the toast id (useful for programmatic dismissal). */ -export function addToast( - level: ToastLevel, - title: string, - message: string, - duration?: number, -): number { +export function addToast(level: ToastLevel, title: string, message: string, duration?: number): number { const id = nextId++; const dur = duration ?? DEFAULT_DURATIONS[level]; toasts.push({ id, level, title, message, duration: dur }); diff --git a/src/lib/window-title.svelte.ts b/src/lib/stores/window-title.svelte.ts similarity index 85% rename from src/lib/window-title.svelte.ts rename to src/lib/stores/window-title.svelte.ts index 84dfdace2..2bbc44a0f 100644 --- a/src/lib/window-title.svelte.ts +++ b/src/lib/stores/window-title.svelte.ts @@ -14,12 +14,14 @@ * * Usage — call once at the top level of a component's + +
+
+ {t("llm.tools.skillsSection")} + + {skills.filter((s) => s.enabled).length}/{skills.length} + +
+ + + +
+
+
+

+ {t("llm.tools.skillsSectionDesc")} +

+ {#if skillsLicense} + + {/if} +
+ {#if skills.length > 0} +
+ + +
+ {/if} +
+ + {#if skillsLicenseOpen && skillsLicense} +
+
{@html skillsLicense.replace(/AI100/g, 'AI100')}
+
+ {/if} +
+ +
+ {#if skillsLoading} +

{t("llm.tools.skillsLoading")}

+ {:else if skills.length === 0} +

{t("llm.tools.skillsNone")}

+ {:else} + {#each skills as skill} + +
(hoveredSkill = skill.name)} + onmouseleave={() => (hoveredSkill = null)}> +
+
+
+ {skill.name} + + {skill.source} + +
+ {@html inlineMd(skill.description)} +
+ +
+
+ {/each} + {/if} +
+
+
+
+ + diff --git a/src/lib/tools/ChatToolsSection.svelte b/src/lib/tools/ChatToolsSection.svelte new file mode 100644 index 000000000..e930078de --- /dev/null +++ b/src/lib/tools/ChatToolsSection.svelte @@ -0,0 +1,448 @@ + + + + +
+
+ {t("llm.tools.section")} + {config.tools.enabled ? toolRows.filter((r) => config.tools[r.key]).length + "/" + toolRows.length : "off"} + {#if configSaving}saving…{/if} +
+ + + +
+

{t("llm.tools.sectionDesc")}

+ +
+ +
+ {#each toolRows as tool} + +
(hoveredTool = tool.key)} + onmouseleave={() => (hoveredTool = null)}> +
+
+
+ {tool.label} + {#if tool.warn} + {t("llm.tools.advanced")} + {/if} +
+ {tool.desc} +
+ +
+ {#if hoveredTool === tool.key} +
+
+

{tool.hint}

+ {#if tool.warn}

{t("llm.tools.advancedHint")}

{/if} +
+ {/if} +
+ {/each} + + {#if config.tools.web_search} +
+
+ {t("llm.tools.searchProvider")} + {t("llm.tools.searchProviderDesc")} +
+ +
+ {#each [ + { key: "duckduckgo" as SearchBackend, label: "DuckDuckGo" }, + { key: "brave" as SearchBackend, label: "Brave" }, + { key: "searxng" as SearchBackend, label: "SearXNG" }, + ] as opt} + + {/each} +
+ + {#if config.tools.web_search_provider.backend === "brave"} +
+ + {t("llm.tools.braveApiKeyDesc")} + { + const val = (e.target as HTMLInputElement).value; + onConfigChange({ ...config, tools: { ...config.tools, web_search_provider: { ...config.tools.web_search_provider, brave_api_key: val } } }); + }} + class="mt-0.5 w-full rounded-lg border border-border/60 dark:border-white/[0.08] bg-surface-3 px-2.5 py-1.5 text-ui-md text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-blue-500/50" /> +
+ {/if} + + {#if config.tools.web_search_provider.backend === "searxng"} +
+ + {t("llm.tools.searxngUrlDesc")} + { + const val = (e.target as HTMLInputElement).value; + onConfigChange({ ...config, tools: { ...config.tools, web_search_provider: { ...config.tools.web_search_provider, searxng_url: val } } }); + }} + class="mt-0.5 w-full rounded-lg border border-border/60 dark:border-white/[0.08] bg-surface-3 px-2.5 py-1.5 text-ui-md text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-blue-500/50" /> +
+ {/if} +
+ {/if} + + {#if config.tools.web_search || config.tools.web_fetch} +
+
+
+ {t("llm.tools.webCache")} + {t("llm.tools.webCacheDesc")} +
+ +
+ + {#if config.tools.web_cache?.enabled ?? true} +
+
+ {t("llm.tools.webCacheSearchTtl")} +
+ {#each [{ secs: 300, label: "5" }, { secs: 900, label: "15" }, { secs: 1800, label: "30" }, { secs: 3600, label: "60" }] as opt} + + {/each} +
+
+
+ {t("llm.tools.webCacheFetchTtl")} +
+ {#each [{ secs: 1800, label: "30" }, { secs: 3600, label: "60" }, { secs: 7200, label: "120" }, { secs: 14400, label: "240" }] as opt} + + {/each} +
+
+
+ +
+
+ + {#if cacheStats.total_entries > 0} + {t("llm.tools.webCacheEntries").replace("{n}", String(cacheStats.total_entries))} + ({t("llm.tools.webCacheSize").replace("{size}", fmtBytes(cacheStats.total_bytes))}) + {:else} + {t("llm.tools.webCacheEmpty")} + {/if} + +
+ + {#if cacheStats.total_entries > 0} + + {/if} +
+
+ + {#if cacheEntries.length > 0} +
+ {#each cacheEntries as entry} +
+
+
+ + {entry.kind === "search" ? t("llm.tools.webCacheSearch") : t("llm.tools.webCacheFetch")} + + {entry.label} +
+
+ {entry.domain}{fmtBytes(entry.bytes)}{fmtAge(entry.created_at)} +
+
+
+ + +
+
+ {/each} +
+ {/if} +
+ {/if} +
+ {/if} +
+ +
+
+ {t("llm.tools.executionMode")} +
+ {#each [{ key: "parallel" as ToolExecutionMode, label: t("llm.tools.parallel") }, { key: "sequential" as ToolExecutionMode, label: t("llm.tools.sequential") }] as mode} + + {/each} +
+
+ +
+
+ {t("llm.tools.maxRounds")} + {t("llm.tools.maxRoundsDesc")} +
+
+ {#each [1, 3, 5, 10, 15] as val} + + {/each} +
+
+ +
+
+ {t("llm.tools.maxCallsPerRound")} + {t("llm.tools.maxCallsPerRoundDesc")} +
+
+ {#each [1, 2, 4, 8] as val} + + {/each} +
+
+ +
+
+ {t("llm.tools.contextCompression")} + {t("llm.tools.contextCompressionDesc")} +
+
+ {#each [ + { key: "off" as CompressionLevel, label: t("llm.tools.compressionOff") }, + { key: "normal" as CompressionLevel, label: t("llm.tools.compressionNormal") }, + { key: "aggressive" as CompressionLevel, label: t("llm.tools.compressionAggressive") }, + ] as opt} + + {/each} +
+ + {#if config.tools.context_compression.level !== "off"} +
+
+ + { + const val = parseInt((e.target as HTMLInputElement).value) || 0; + await onConfigChange({ + ...config, + tools: { + ...config.tools, + context_compression: { + ...config.tools.context_compression, + max_search_results: Math.max(0, Math.min(20, val)), + }, + }, + }); + }} + class="w-full rounded-lg border border-border/60 dark:border-white/[0.08] bg-surface-3 px-2.5 py-1.5 text-ui-md text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-blue-500/50" /> + {t("llm.tools.zeroAutoLabel")} +
+
+ + { + const val = parseInt((e.target as HTMLInputElement).value) || 0; + await onConfigChange({ + ...config, + tools: { + ...config.tools, + context_compression: { + ...config.tools.context_compression, + max_result_chars: Math.max(0, Math.min(32000, val)), + }, + }, + }); + }} + class="w-full rounded-lg border border-border/60 dark:border-white/[0.08] bg-surface-3 px-2.5 py-1.5 text-ui-md text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-blue-500/50" /> + {t("llm.tools.zeroAutoLabel")} +
+
+ {/if} +
+ +
+
+ {t("llm.tools.retrySection")} + {t("llm.tools.retrySectionDesc")} +
+ +
+
+ +
+ {#each [0, 1, 2, 3] as val} + + {/each} +
+ {t("llm.tools.retryMaxRetriesDesc")} +
+
+ +
+ {#each [500, 1000, 2000, 3000] as val} + + {/each} +
+ {t("llm.tools.retryBaseDelayDesc")} +
+
+
+
+
+
+
diff --git a/src/lib/tools/SkillsRefreshSection.svelte b/src/lib/tools/SkillsRefreshSection.svelte new file mode 100644 index 000000000..936770a9f --- /dev/null +++ b/src/lib/tools/SkillsRefreshSection.svelte @@ -0,0 +1,97 @@ + + + + +
+
+ {t("llm.tools.skillsRefresh")} +
+ + + +
+

+ {t("llm.tools.skillsRefreshDesc")} +

+
+ +
+
+
+ {t("llm.tools.skillsRefresh")} +
+
+ {#each [ + { secs: 0, label: t("llm.tools.skillsRefreshOff") }, + { secs: 43200, label: t("llm.tools.skillsRefresh12h") }, + { secs: 86400, label: t("llm.tools.skillsRefresh24h") }, + { secs: 604800, label: t("llm.tools.skillsRefresh7d") }, + ] as opt} + + {/each} +
+
+ + + +
+
+ + {t("llm.tools.skillsLastSync")}: {formatLastSync(skillsLastSync)} + +
+ +
+
+
+
+
diff --git a/src/lib/tools/SuggestSkillCta.svelte b/src/lib/tools/SuggestSkillCta.svelte new file mode 100644 index 000000000..c56d88ecc --- /dev/null +++ b/src/lib/tools/SuggestSkillCta.svelte @@ -0,0 +1,25 @@ + + + + +
+ + + + +
+ + {t("llm.tools.suggestSkill")} + + + {t("llm.tools.suggestSkillDesc")} + +
+ +
+
diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 000000000..1e15bf7c6 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Shared TypeScript interfaces used across multiple pages/components. + +// ── Muse / BCI device status ───────────────────────────────────────────────── + +export interface PairedDevice { + id: string; + name: string; + last_seen: number; +} + +export interface DiscoveredDevice { + id: string; + name: string; + last_rssi: number; + is_paired: boolean; + is_preferred: boolean; +} + +export type PowerlineFreq = "Hz60" | "Hz50"; + +export interface FilterConfig { + sample_rate: number; + low_pass_hz: number | null; + high_pass_hz: number | null; + notch: PowerlineFreq | null; + notch_bandwidth_hz: number; +} + +export interface DeviceStatus { + state: "disconnected" | "scanning" | "connecting" | "connected" | "bt_off"; + device_name: string | null; + device_id: string | null; + /** Factory serial number from the headset ("sn" field), e.g. "AAAA-BBBB-CCCC". Arrives a few seconds after connect. */ + serial_number: string | null; + /** Hardware MAC address from the headset ("ma" field), e.g. "AA-BB-CC-DD-EE-FF". */ + mac_address: string | null; + csv_path: string | null; + sample_count: number; + battery: number; + eeg: number[]; + paired_devices: PairedDevice[]; + device_error: string | null; + target_name: string | null; + /** Canonical target id chosen by daemon for (re)connect attempts. */ + target_id?: string | null; + /** Human-readable target name resolved by daemon from paired metadata. */ + target_display_name?: string | null; + filter_config: FilterConfig; + /** Per-channel quality in electrode order [TP9, AF7, AF8, TP10]. + * Values: "good" | "fair" | "poor" | "no_signal" */ + channel_quality: string[]; + /** Current auto-retry attempt (0 = not retrying). */ + retry_attempt: number; + /** Seconds remaining until next auto-retry (0 = not counting down). */ + retry_countdown_secs: number; + /** Latest raw PPG values [ambient, infrared, red]. */ + ppg: number[]; + /** Total PPG samples received this session. */ + ppg_sample_count: number; + /** Total IMU samples received this session. */ + imu_sample_count: number; + /** Latest accelerometer reading [x, y, z] in g. */ + accel: [number, number, number]; + /** Latest gyroscope reading [x, y, z] in °/s. */ + gyro: [number, number, number]; + /** Battery fuel-gauge voltage in mV (Classic only, 0 on Athena). */ + fuel_gauge_mv: number; + /** Raw temperature ADC value (Classic only, 0 on Athena). */ + temperature_raw: number; + /** Which device family is connected (see `DeviceKind` in device.rs). */ + device_kind: string; + /** Hardware model code, e.g. "p21" = Muse 2. */ + hardware_version: string | null; + /** Firmware version string from the device, e.g. "3.4.5" (Muse). Arrives a few seconds after connect. */ + firmware_version?: string | null; + /** Bootloader version string from the device, if reported. */ + bootloader_version?: string | null; + /** Device has a PPG (heart-rate) sensor. */ + has_ppg: boolean; + /** Device has an IMU (accelerometer + gyroscope). */ + has_imu: boolean; + /** Device has electrodes at central scalp sites (C3/C4/Cz). */ + has_central_electrodes: boolean; + /** Device supports a full 10-20 montage (or superset). */ + has_full_montage: boolean; + /** EEG channel labels from the connected device (e.g. ["AF3","T7","Pz","T8","AF4"] for Insight). */ + channel_names?: string[]; + /** PPG channel labels from the connected device. */ + ppg_channel_names?: string[]; + /** IMU channel labels from the connected device. */ + imu_channel_names?: string[]; + /** fNIRS channel labels from the connected device. */ + fnirs_channel_names?: string[]; + /** fNIRS oxygenation proxy (0–100). */ + fnirs_oxygenation_pct?: number; + /** fNIRS workload proxy (0–100). */ + fnirs_workload?: number; + /** fNIRS left-vs-right lateralization proxy (-100..100). */ + fnirs_lateralization?: number; + /** fNIRS ΔHbO left proxy (a.u.). */ + fnirs_hbo_left?: number; + /** fNIRS ΔHbO right proxy (a.u.). */ + fnirs_hbo_right?: number; + /** fNIRS ΔHbR left proxy (a.u.). */ + fnirs_hbr_left?: number; + /** fNIRS ΔHbR right proxy (a.u.). */ + fnirs_hbr_right?: number; + /** fNIRS ΔHbT left proxy (a.u.). */ + fnirs_hbt_left?: number; + /** fNIRS ΔHbT right proxy (a.u.). */ + fnirs_hbt_right?: number; + /** fNIRS left-right connectivity proxy (Pearson r). */ + fnirs_connectivity?: number; + /** Hardware EEG channel count (e.g. 5 for Insight, 14 for EPOC X). */ + eeg_channel_count?: number; + /** Hardware EEG sample rate in Hz (e.g. 128 for Emotiv). */ + eeg_sample_rate_hz?: number; + /** Phone descriptor from the remote iOS client (model, OS, locale, etc.). */ + phone_info?: { + phone_model?: string; + phone_name?: string; + phone_marketing_name?: string; + os?: string; + os_version?: string; + app_version?: string; + battery_level?: number; + battery_state?: string; + iroh_endpoint_id?: string; + photo_url?: string; + avatar_url?: string; + image_url?: string; + photo_base64?: string; + avatar_base64?: string; + image_base64?: string; + [k: string]: unknown; + } | null; + /** Display name of the connected iroh client (from the auth store). */ + iroh_client_name?: string | null; + /** True when at least one iroh tunnel peer is currently online. */ + iroh_tunnel_online?: boolean; + /** Count of active iroh device-proxy peers. */ + iroh_connected_peers?: number; + /** True when iOS reports a remote BLE headset connected. */ + iroh_remote_device_connected?: boolean; + /** True when recent sensor chunks are actively flowing over iroh. */ + iroh_streaming_active?: boolean; + /** True when recent EEG-bearing chunks are actively flowing over iroh. */ + iroh_eeg_streaming_active?: boolean; + /** Catch-all for future fields not yet typed. */ + [k: string]: unknown; +} + +// ── Muse electrode constants ───────────────────────────────────────────────── + +export const MUSE_CHANNELS = ["TP9", "AF7", "AF8", "TP10"] as const; +export const MUSE_POSITIONS = ["Left ear", "Left forehead", "Right forehead", "Right ear"] as const; + +// ── Sleep types ────────────────────────────────────────────────────────────── + +export interface SleepEpoch { + utc: number; + stage: number; // 0=Wake, 1=N1, 2=N2, 3=N3, 5=REM + rel_delta: number; + rel_theta: number; + rel_alpha: number; + rel_beta: number; +} + +export interface SleepSummary { + total_epochs: number; + wake_epochs: number; + n1_epochs: number; + n2_epochs: number; + n3_epochs: number; + rem_epochs: number; + epoch_secs: number; +} + +export interface SleepStages { + epochs: SleepEpoch[]; + summary: SleepSummary; +} + +// ── UMAP types ─────────────────────────────────────────────────────────────── + +export interface UmapPoint { + x: number; + y: number; + z?: number; + session: number; + utc: number; + label?: string; + /** Semantic distance from the query anchor (used in kNN graph tooltips). */ + dist?: number; +} + +export interface UmapResult { + points: UmapPoint[]; + n_a: number; + n_b: number; + dim: number; + elapsed_ms?: number; +} + +export interface UmapProgress { + epoch: number; + total_epochs: number; + loss: number; + best_loss: number; + elapsed_secs: number; + epoch_ms: number; +} + +// ── Label types ────────────────────────────────────────────────────────────── + +export interface LabelRow { + id: number; + eeg_start: number; + eeg_end: number; + label_start: number; + label_end: number; + text: string; + context: string; + created_at: number; +} diff --git a/src/lib/UmapCanvas.svelte b/src/lib/umap/UmapCanvas.svelte similarity index 51% rename from src/lib/UmapCanvas.svelte rename to src/lib/umap/UmapCanvas.svelte index 59fc27297..87236f3ae 100644 --- a/src/lib/UmapCanvas.svelte +++ b/src/lib/umap/UmapCanvas.svelte @@ -9,26 +9,19 @@ the Free Software Foundation, version 3 only. --> This avoids svelte:component issues with Threlte's snippet children. --> diff --git a/src/lib/umap/UmapScene.svelte b/src/lib/umap/UmapScene.svelte new file mode 100644 index 000000000..5b74ce093 --- /dev/null +++ b/src/lib/umap/UmapScene.svelte @@ -0,0 +1,489 @@ + + + + + + + diff --git a/src/lib/umap/UmapViewer3D.svelte b/src/lib/umap/UmapViewer3D.svelte new file mode 100644 index 000000000..12bae5043 --- /dev/null +++ b/src/lib/umap/UmapViewer3D.svelte @@ -0,0 +1,1983 @@ + + + + + + +
+ + + {#if traceActive && traceTimeRange && traceTimeTicks.length > 0} + {@const prog = traceTotal > 0 ? traceProgress / traceTotal : 0} +
+
+ +
+ +
+
+ +
+ {#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} + + {/each} +
+
+
+ {/if} + +
+ + + {#if sidebarOpen && uniqueLabels.length > 0} + {@const groups = [ + { key: "common", label: t("umap.common"), items: uniqueLabels.filter(l => l.inA && l.inB) }, + { key: "a-only", label: t("umap.sessionA"), items: uniqueLabels.filter(l => l.inA && !l.inB) }, + { key: "b-only", label: t("umap.sessionB"), items: uniqueLabels.filter(l => !l.inA && l.inB) }, + ].filter(g => g.items.length > 0)} +
+ +
+ + {t("umap.labels")} + + + + { sidebarOpen = false; selectedLabel = null; applyHighlight(); }}>✕ +
+ +
+ + + {t("umap.sessionA")} + + + + {t("umap.sessionB")} + +
+ + +
+ {#each groups as group} + +
+ {group.label} +
+
+ + {#each group.items as entry} + {@const isSelected = selectedLabel === entry.label} + {@const isProximate = proximateLabels.includes(entry.label)} + {@const hex = labelHex(entry.hue)} + + +
{ selectedLabel = selectedLabel === entry.label ? null : entry.label; applyHighlight(); }}> + +
+ {#if entry.inA && entry.inB} + + + + {:else if entry.inA} + + {:else} + + {/if} + {entry.label} +
+ {#if entry.inA} + A + {/if} + {#if entry.inB} + B + {/if} +
+
+ +
+ {#each entry.timestamps.slice(0, 6) as ts} + + {fmtUtcTime(ts.utc)} + + {/each} + {#if entry.timestamps.length > 6} + + +{entry.timestamps.length - 6} + + {/if} +
+
+ {/each} + {/each} +
+ + +
+
+ + {t("umap.proximity")} + + + {proximityDist.toFixed(1)} + +
+ +
+ {#if selectedLabel && !animating} + {#if proximateLabels.length > 0} + + {proximateLabels.length} {t("umap.nearbyLabels")} + + {:else} + {t("umap.noNearbyLabels")} + {/if} + {:else if animating} + {t("umap.computing")} + {:else} + {t("umap.selectLabelHint")} + {/if} +
+
+
+ {/if} + + +
+ + + {#if cmdHeld} +
+ ⌘ Pan +
+ {/if} + + {#if error} +
+ {t("umap.error")} +
{error}
+
+ {:else if !loaded} +
+
+ + {t("umap.loading")} +
+
+ {/if} + + + {#if computing} +
+
+
+
+ +
+ {t("umap.computing")} + {#if progress && progress.total_epochs > 0} + {@const pct = Math.round(progress.epoch / progress.total_epochs * 100)} + +
+
+
+
+ {pct}% +
+ + + epoch {progress.epoch}/{progress.total_epochs} · {progress.epoch_ms.toFixed(0)}ms/ep + + {:else} + {t("umap.placeholder")} + {/if} +
+
+ {/if} + + + {#if activeLabels.length > 0} +
+ {#each activeLabels as label} + {@const entry = linkGroups.get(label)} + {@const col = entry ? LINK_PALETTE[entry.colorIdx % LINK_PALETTE.length] : 0xffffff} +
+ + {label} + + + removeLinkGroup(label)}>✕ +
+ {/each} + {#if activeLabels.length > 1} + + +
clearAllLinks()}> + {t("umap.clearAll")} +
+ {/if} +
+ {/if} + + +
+ + + {#if timeGradient && gradientRange} + {@const span = gradientRange.maxUtc - gradientRange.minUtc} +
+ + Session {timeGradient} · time → + +
+ + {fmtGradientTs(gradientRange.minUtc, span)} + +
+
+ + {fmtGradientTs(gradientRange.maxUtc, span)} + +
+
+ {/if} + + + {#if !timeGradient && colorByDate && dateLegend.length > 0} +
+ {#each dateLegend as dl} +
+ + {dl.date} +
+ {/each} +
+ {/if} + + + {#if loaded && !error && uniqueLabels.length > 0} + {@const colAcss = `#${UMAP_COLOR_A.toString(16).padStart(6,"0")}`} + {@const colBcss = `#${UMAP_COLOR_B.toString(16).padStart(6,"0")}`} +
+ + +
+ + {t("umap.sessionA")} — {t("umap.shapeSphere")} +
+
+ + {t("umap.sessionB")} — {t("umap.shapeDiamond")} +
+ + + {#if selectedLabel} +
+
+ + {selectedLabel} + {t("umap.legendSelected")} +
+ {#if proximateLabels.length > 0} +
+ + {proximateLabels.length} {t("umap.legendNearby")} +
+ {:else} +
+ + {t("umap.noNearbyLabels")} +
+ {/if} +
+ + {t("umap.legendDimmed")} +
+ {/if} +
+ {/if} +
+ + + {#if loaded && !error && curPoints.length >= 2} +
+ + + +
+
{ timeGradient = timeGradient === 'A' ? null : 'A'; }}> + 🌈 A +
+
+
{ timeGradient = timeGradient === 'B' ? null : 'B'; }}> + 🌈 B +
+
+ + + + +
+ + {traceActive ? t("umap.traceStop") : t("umap.trace")} + + {#if traceActive && traceTotal > 0} + {traceProgress}/{traceTotal} + {/if} +
+ + + {#if uniqueLabels.length > 0} + + +
{ sidebarOpen = !sidebarOpen; if (!sidebarOpen) { selectedLabel = null; applyHighlight(); } }}> + + 🏷 {uniqueLabels.length} + +
+ {/if} + + + + +
+ +
+ {#if exportFlash === "png"} + + + + + {t("umap.exportedPng")} + {:else} + + + + + + {t("umap.exportPng")} + {/if} +
+ +
+ +
+ {#if exportFlash === "json"} + + + + {t("umap.exportedJson")} + {:else} + + + + + + {t("umap.exportJson")} + {/if} +
+
+
+ {/if} + + + {#if loaded && !error} +
+ {t("umap.nodeSize")} + + {pointScale.toFixed(1)}× +
+ {/if} + + + {#if tooltip} +
+ {tooltip.text} +
+ {/if} +
+
+
+ + diff --git a/src/lib/umap/umap-helpers.ts b/src/lib/umap/umap-helpers.ts new file mode 100644 index 000000000..31a15bf08 --- /dev/null +++ b/src/lib/umap/umap-helpers.ts @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Pure math, color, and geometry helpers for the UMAP 3D viewer. + * + * Extracted from `UmapViewer3D.svelte` to reduce file size + * and enable independent testing. + */ + +import type { UmapPoint } from "$lib/types"; + +// ── Easing ─────────────────────────────────────────────────────────────────── + +export function easeOut(t: number): number { + return 1 - (1 - t) ** 3; +} + +export function gauss(): number { + return Math.sqrt(-2 * Math.log(Math.random() || 1e-10)) * Math.cos(Math.PI * 2 * Math.random()); +} + +// ── Color helpers ──────────────────────────────────────────────────────────── + +export function hslToRgb(h: number, s: number, l: number): [number, number, number] { + const a = s * Math.min(l, 1 - l); + const f = (n: number) => { + const k = (n + h / 30) % 12; + return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + }; + return [f(0), f(8), f(4)]; +} + +export function labelHex(hue: number): string { + const [r, g, b] = hslToRgb(hue, 0.85, 0.55); + return `#${[r, g, b] + .map((v) => + Math.round(v * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; +} + +/** Turbo colormap — returns raw [R, G, B] in 0–1 range. */ +export function turboRaw(t: number): [number, number, number] { + const c = Math.max(0, Math.min(1, t)); + const r = Math.max( + 0, + Math.min( + 1, + 0.13572138 + c * (4.6153926 + c * (-42.66032258 + c * (132.13108234 + c * (-152.54893924 + c * 59.28637943)))), + ), + ); + const g = Math.max( + 0, + Math.min( + 1, + 0.09140261 + c * (2.19418839 + c * (4.84296658 + c * (-14.18503333 + c * (4.27729857 + c * 2.82956604)))), + ), + ); + const b = Math.max( + 0, + Math.min( + 1, + 0.1066733 + c * (12.64194608 + c * (-60.58204836 + c * (110.36276771 + c * (-89.90310912 + c * 27.34824973)))), + ), + ); + return [r, g, b]; +} + +/** Jet colormap — returns raw [R, G, B] in 0–1 range. */ +export function jet(t: number): [number, number, number] { + const c = Math.max(0, Math.min(1, t)); + return [ + Math.min(1, Math.max(0, 1.5 - Math.abs(4 * c - 3))), + Math.min(1, Math.max(0, 1.5 - Math.abs(4 * c - 2))), + Math.min(1, Math.max(0, 1.5 - Math.abs(4 * c - 1))), + ]; +} + +/** Jet colormap → CSS hex string. */ +export function jetHex(t: number): string { + const [r, g, b] = jet(t); + return `#${[r, g, b] + .map((v) => + Math.round(v * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; +} + +// ── Timestamp formatting ───────────────────────────────────────────────────── + +/** Format a UTC timestamp for the gradient legend, adapting precision to span. */ +export function fmtGradientTs(utc: number, span: number): string { + if (utc <= 0) return ""; + const d = new Date(utc * 1000); + if (span >= 172800) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + if (span >= 3600) return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +/** Format a UTC timestamp as "HH:MM" local time. */ +export function fmtUtcTime(utc: number): string { + const d = new Date(utc * 1000); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +/** Convert UTC seconds → local "YYYY-MM-DD" string. */ +export function utcToLocalDate(utc: number): string { + const d = new Date(utc * 1000); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +// ── Geometry ───────────────────────────────────────────────────────────────── + +/** Normalise UMAP points into a [-1, 1]³ cube centered at the centroid. */ +export function normalise(pts: UmapPoint[]): Float32Array { + const n = pts.length; + const pos = new Float32Array(n * 3); + if (n === 0) return pos; + let mx = 0, + my = 0, + mz = 0; + for (const p of pts) { + mx += p.x; + my += p.y; + mz += p.z ?? 0; + } + mx /= n; + my /= n; + mz /= n; + let maxR = 0; + for (const p of pts) { + const dx = p.x - mx, + dy = p.y - my, + dz = (p.z ?? 0) - mz; + maxR = Math.max(maxR, Math.sqrt(dx * dx + dy * dy + dz * dz)); + } + if (maxR < 1e-8) maxR = 1; + for (let i = 0; i < n; i++) { + pos[i * 3] = (pts[i].x - mx) / maxR; + pos[i * 3 + 1] = (pts[i].y - my) / maxR; + pos[i * 3 + 2] = ((pts[i].z ?? 0) - mz) / maxR; + } + return pos; +} + +/** Generate random initial positions for animation. */ +export function randomPositions(pts: UmapPoint[]): Float32Array { + const n = pts.length; + const pos = new Float32Array(n * 3); + for (let i = 0; i < n; i++) { + pos[i * 3] = gauss() * 0.15; + pos[i * 3 + 1] = gauss() * 0.15; + pos[i * 3 + 2] = gauss() * 0.15; + } + return pos; +} + +/** + * Build tick positions for the trace gradient legend. + * Returns array of { t: number (0-1), label: string }. + */ +export function buildTraceTimeTicks(sorted: number[]): { t: number; label: string }[] { + if (sorted.length < 2) return []; + const tMin = sorted[0]; + const tMax = sorted[sorted.length - 1]; + const span = tMax - tMin; + if (span <= 0) return []; + + // Choose a "nice" tick interval + const TARGET_TICKS = 5; + const raw = span / TARGET_TICKS; + const niceIntervals = [60, 120, 300, 600, 900, 1800, 3600, 7200, 14400, 28800, 43200, 86400]; + const interval = niceIntervals.find((i) => i >= raw) ?? 86400; + + // Generate ticks at whole multiples of interval + const firstTick = Math.ceil(tMin / interval) * interval; + const ticks: { t: number; label: string }[] = []; + for (let ts = firstTick; ts <= tMax; ts += interval) { + const normT = (ts - tMin) / span; + if (normT < 0.02 || normT > 0.98) continue; // skip ticks too close to edges + ticks.push({ t: normT, label: fmtGradientTs(ts, span) }); + } + + // Always include endpoints if we have room + if (ticks.length === 0 || ticks[0].t > 0.1) { + ticks.unshift({ t: 0, label: fmtGradientTs(tMin, span) }); + } + if (ticks.length === 0 || ticks[ticks.length - 1].t < 0.9) { + ticks.push({ t: 1, label: fmtGradientTs(tMax, span) }); + } + return ticks; +} + +/** + * Build a date → color palette for multi-day UMAP coloring. + * Returns a Map from local date string to [r, g, b] tuple. + */ +export function buildDatePaletteRaw(pts: UmapPoint[]): Map { + const daySet = new Set(); + for (const p of pts) { + if (p.utc > 0) daySet.add(utcToLocalDate(p.utc)); + } + const days = [...daySet].sort(); + const palette = new Map(); + for (let i = 0; i < days.length; i++) { + const hue = (i / Math.max(days.length, 1)) * 360; + palette.set(days[i], hslToRgb(hue, 0.8, 0.55)); + } + return palette; +} diff --git a/src/lib/umap/umap-viewer-logic.ts b/src/lib/umap/umap-viewer-logic.ts new file mode 100644 index 000000000..14707d8ae --- /dev/null +++ b/src/lib/umap/umap-viewer-logic.ts @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * UmapViewer3D pure logic — extracted from UmapViewer3D.svelte. + * + * Geometry normalization, color mapping, and point cloud generation + * for the 3D UMAP scatter plot. No Three.js or Svelte dependencies. + */ + +import type { UmapPoint } from "$lib/types"; + +// ── Turbo colormap ──────────────────────────────────────────────────────────── + +/** + * Attempt to apply a Turbo-like RGB mapping for a parameter t in [0, 1]. + * Falls back to a simple blue→cyan→green→yellow→red gradient. + */ +export function turboApprox(t: number): [number, number, number] { + const tc = Math.max(0, Math.min(1, t)); + // Attempt a piecewise approximation of the Turbo colormap + const r = Math.max(0, Math.min(1, 1.5 - Math.abs(tc - 0.75) * 4)); + const g = Math.max(0, Math.min(1, 1.5 - Math.abs(tc - 0.5) * 4)); + const b = Math.max(0, Math.min(1, 1.5 - Math.abs(tc - 0.25) * 4)); + return [r, g, b]; +} + +/** Convert RGB [0-1] triple to a hex color string. */ +export function rgbToHex(r: number, g: number, b: number): string { + const hex = (v: number) => + Math.round(v * 255) + .toString(16) + .padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; +} + +// ── Point cloud geometry ────────────────────────────────────────────────────── + +/** + * Normalize UMAP points into a [-1, 1]^3 cube centered at origin. + * Returns a Float32Array of xyz triples (length = points.length * 3). + */ +export function normalisePoints(pts: UmapPoint[]): Float32Array { + const n = pts.length; + const out = new Float32Array(n * 3); + let mnX = Infinity, + mxX = -Infinity, + mnY = Infinity, + mxY = -Infinity, + mnZ = Infinity, + mxZ = -Infinity; + + for (const p of pts) { + const z = p.z ?? 0; + if (p.x < mnX) mnX = p.x; + if (p.x > mxX) mxX = p.x; + if (p.y < mnY) mnY = p.y; + if (p.y > mxY) mxY = p.y; + if (z < mnZ) mnZ = z; + if (z > mxZ) mxZ = z; + } + + const rX = mxX - mnX || 1; + const rY = mxY - mnY || 1; + const rZ = mxZ - mnZ || 1; + const scale = Math.max(rX, rY, rZ); + + for (let i = 0; i < n; i++) { + const p = pts[i]; + out[i * 3] = ((p.x - mnX) / scale) * 2 - 1; + out[i * 3 + 1] = ((p.y - mnY) / scale) * 2 - 1; + out[i * 3 + 2] = (((p.z ?? 0) - mnZ) / scale) * 2 - 1; + } + return out; +} + +/** + * Generate random xyz positions in [-1, 1]^3 for initial point-cloud animation. + */ +export function randomPositions(count: number, seed = 42): Float32Array { + const out = new Float32Array(count * 3); + // Simple deterministic pseudo-random (mulberry32) + let s = seed | 0; + const rand = () => { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + for (let i = 0; i < out.length; i++) { + out[i] = rand() * 2 - 1; + } + return out; +} + +/** + * Compute per-point color array based on a normalized parameter (0-1 per point). + * Returns a Float32Array of rgb triples. + */ +export function buildColorArray(values: number[], isDark: boolean, dimFactor = 0.75): Float32Array { + const out = new Float32Array(values.length * 3); + for (let i = 0; i < values.length; i++) { + let [r, g, b] = turboApprox(values[i]); + if (!isDark) { + r *= dimFactor; + g *= dimFactor; + b *= dimFactor; + } + out[i * 3] = r; + out[i * 3 + 1] = g; + out[i * 3 + 2] = b; + } + return out; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 43fd8f5d0..3004521c1 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,16 +4,16 @@ // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, version 3 only. -import { clsx, type ClassValue } from "clsx"; +import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// biome-ignore lint/suspicious/noExplicitAny: structural type utility — any is intentional for conditional type matching export type WithoutChild = T extends { child?: any } ? Omit : T; export type WithoutChildrenOrChild = WithoutChildren>; -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// biome-ignore lint/suspicious/noExplicitAny: structural type utility — any is intentional for conditional type matching export type WithoutChildren = T extends { children?: any } ? Omit : T; export type WithElementRef = T & { ref?: U | null }; diff --git a/src/lib/utils/error.ts b/src/lib/utils/error.ts new file mode 100644 index 000000000..d77fe9224 --- /dev/null +++ b/src/lib/utils/error.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-only + +/** Convert unknown thrown values to a display-safe message. */ +export function toErrorMessage(err: unknown, fallback = "failed"): string { + if (err instanceof Error && err.message) return err.message; + if (typeof err === "string" && err.length > 0) return err; + if (err == null) return fallback; + return String(err); +} + +/** Build a localized prefixed error message (e.g. "Error: ..."). */ +export function formatPrefixedError(prefix: string, err: unknown, fallback = "failed"): string { + return `${prefix}: ${toErrorMessage(err, fallback)}`; +} diff --git a/src/lib/utils/fuzzy-utils.ts b/src/lib/utils/fuzzy-utils.ts new file mode 100644 index 000000000..fef8f7af2 --- /dev/null +++ b/src/lib/utils/fuzzy-utils.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +/** + * Damerau-Levenshtein distance for typo tolerance in Cmd-K search. + */ + +/** + * Compute the Damerau-Levenshtein distance between two strings. + * Supports insertions, deletions, substitutions, and transpositions. + */ +export function damerauLevenshtein(a: string, b: string): number { + const la = a.length; + const lb = b.length; + if (la === 0) return lb; + if (lb === 0) return la; + + // Use flat array for speed + const d = new Uint16Array((la + 1) * (lb + 1)); + const w = lb + 1; + + for (let i = 0; i <= la; i++) d[i * w] = i; + for (let j = 0; j <= lb; j++) d[j] = j; + + for (let i = 1; i <= la; i++) { + for (let j = 1; j <= lb; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + d[i * w + j] = Math.min( + d[(i - 1) * w + j] + 1, // deletion + d[i * w + (j - 1)] + 1, // insertion + d[(i - 1) * w + (j - 1)] + cost, // substitution + ); + // transposition + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { + d[i * w + j] = Math.min(d[i * w + j], d[(i - 2) * w + (j - 2)] + cost); + } + } + } + return d[la * w + lb]; +} + +/** + * Check if query approximately matches any word in text. + * Returns a reduced score if a word-level edit distance <= maxDist, else null. + */ +export function typoMatch(query: string, text: string, maxDist = 2): number | null { + const q = query.toLowerCase(); + const words = text + .toLowerCase() + .split(/[\s\-_./›]+/) + .filter((w) => w.length > 0); + + let bestScore = Infinity; + for (const word of words) { + // Only compare if lengths are reasonably close + if (Math.abs(word.length - q.length) > maxDist) continue; + const dist = damerauLevenshtein(q, word); + if (dist <= maxDist && dist < bestScore) { + bestScore = dist; + } + } + + if (bestScore === Infinity) return null; + // Lower distance = higher score. Max score for dist=1 is ~5, dist=2 is ~2 + return Math.max(1, 8 - bestScore * 3); +} diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts new file mode 100644 index 000000000..31a3999ba --- /dev/null +++ b/src/lib/utils/logger.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright (C) 2026 NeuroSkill.com +// +// Structured frontend logger. +// +// In production builds, console.log / console.debug are stripped by esbuild +// (see vite.config.js). This module provides a thin wrapper so call-sites +// are self-documenting and easy to grep. +// +// Usage: +// import { log } from "$lib/utils/logger"; +// log.info("device connected", { name: device.name }); +// log.warn("retrying BLE scan"); +// log.error("websocket closed", error); +// log.debug("raw EEG sample", sample); // stripped in production + +const isDev = import.meta.env.DEV; + +function timestamp(): string { + return new Date().toISOString(); +} + +/* biome-ignore lint/suspicious/noConsole: this IS the logger */ +const _debug = console.debug.bind(console); +/* biome-ignore lint/suspicious/noConsole: this IS the logger */ +const _log = console.log.bind(console); +/* biome-ignore lint/suspicious/noConsole: this IS the logger */ +const _warn = console.warn.bind(console); +/* biome-ignore lint/suspicious/noConsole: this IS the logger */ +const _error = console.error.bind(console); + +export const log = { + /** Debug-level: stripped from production builds. */ + debug(...args: unknown[]): void { + if (isDev) _debug(`[${timestamp()}] [DEBUG]`, ...args); + }, + + /** Informational: stripped from production builds. */ + info(...args: unknown[]): void { + if (isDev) _log(`[${timestamp()}] [INFO]`, ...args); + }, + + /** Warning: preserved in production. */ + warn(...args: unknown[]): void { + _warn(`[${timestamp()}] [WARN]`, ...args); + }, + + /** Error: preserved in production. */ + error(...args: unknown[]): void { + _error(`[${timestamp()}] [ERROR]`, ...args); + }, +}; diff --git a/src/lib/utils/markdown-normalize.ts b/src/lib/utils/markdown-normalize.ts new file mode 100644 index 000000000..56119b85f --- /dev/null +++ b/src/lib/utils/markdown-normalize.ts @@ -0,0 +1,116 @@ +export function normalizeMarkdown(raw: string): string { + const sanitized = stripLeadingOrphanJsonFence(raw); + const slots: string[] = []; + + const protectedText = sanitized + // Preserve fenced code blocks verbatim. + .replace(/```[\s\S]*?```/g, (block) => stash(block, slots)) + // Preserve inline code spans so emphasis repair does not rewrite examples. + .replace(/`[^`\n]*`/g, (code) => stash(code, slots)); + + const normalized = normalizeEmphasisRuns(normalizeEmphasisRuns(protectedText, "**", "strong"), "*", "em"); + + return normalized.replace(/\uE000(\d+)\uE001/g, (_, index) => slots[Number(index)] ?? ""); +} + +function stash(text: string, slots: string[]): string { + const id = slots.push(text) - 1; + return `\uE000${id}\uE001`; +} + +function stripLeadingOrphanJsonFence(raw: string): string { + const open = raw.match(/^\s*```(?:json)?\s*\n/i); + if (!open) return raw; + + const rest = raw.slice(open[0].length); + if (/^```|\n```/m.test(rest)) return raw; + + const lines = rest.split("\n"); + let idx = 0; + let sawJsonish = false; + + while (idx < lines.length) { + const line = lines[idx].trim(); + + if (!line) { + idx += 1; + if (sawJsonish) { + const next = lines.slice(idx).find((candidate) => candidate.trim()); + if (next && !looksLikeJsonFragmentLine(next.trim())) break; + } + continue; + } + + if (!looksLikeJsonFragmentLine(line)) break; + sawJsonish = true; + idx += 1; + } + + if (!sawJsonish) return raw; + + return lines.slice(idx).join("\n").trimStart(); +} + +function looksLikeJsonFragmentLine(line: string): boolean { + return ( + /^[{}[\],]+$/.test(line) || + /^"[^\n]*$/.test(line) || + /^[A-Za-z0-9_.$-]+"?\s*:\s*/.test(line) || + /^(true|false|null),?$/.test(line) || + /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?,?$/.test(line) + ); +} + +function normalizeEmphasisRuns(text: string, delimiter: "**" | "*", tag: "strong" | "em"): string { + const pattern = + delimiter === "**" + ? /(? { + const trimmed = inner.trim(); + if (!trimmed) return match; + + const left = source[offset - 1] ?? ""; + const right = source[offset + match.length] ?? ""; + const first = trimmed[0] ?? ""; + const last = trimmed[trimmed.length - 1] ?? ""; + + const canOpen = delimiterCanOpen(left, first); + const canClose = delimiterCanClose(last, right); + + if (!canOpen || !canClose) { + return `<${tag}>${trimmed}`; + } + + return `${delimiter}${trimmed}${delimiter}`; + }); +} + +function delimiterCanOpen(prev: string, next: string): boolean { + const leftFlanking = isLeftFlanking(prev, next); + const rightFlanking = isRightFlanking(prev, next); + return leftFlanking && (!rightFlanking || isPunctuation(prev)); +} + +function delimiterCanClose(prev: string, next: string): boolean { + const leftFlanking = isLeftFlanking(prev, next); + const rightFlanking = isRightFlanking(prev, next); + return rightFlanking && (!leftFlanking || isPunctuation(next)); +} + +function isLeftFlanking(prev: string, next: string): boolean { + return !isWhitespace(next) && (!isPunctuation(next) || isWhitespace(prev) || isPunctuation(prev)); +} + +function isRightFlanking(prev: string, next: string): boolean { + return !isWhitespace(prev) && (!isPunctuation(prev) || isWhitespace(next) || isPunctuation(next)); +} + +function isWhitespace(char: string): boolean { + return char === "" || /\s/u.test(char); +} + +function isPunctuation(char: string): boolean { + return char !== "" && /[\p{P}\p{S}]/u.test(char); +} diff --git a/src/lib/virtual-eeg/generator.ts b/src/lib/virtual-eeg/generator.ts new file mode 100644 index 000000000..81e42eb40 --- /dev/null +++ b/src/lib/virtual-eeg/generator.ts @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Virtual EEG signal generator — produces synthetic multichannel EEG data. + +export type SignalTemplate = "sine" | "good_quality" | "bad_quality" | "interruptions" | "file"; +export type SignalQuality = "poor" | "fair" | "good" | "excellent"; +export type LineNoise = "none" | "50hz" | "60hz"; + +export interface VirtualEegConfig { + channels: number; + sampleRate: number; + template: SignalTemplate; + quality: SignalQuality; + amplitudeUv: number; + noiseUv: number; + lineNoise: LineNoise; + dropoutProb: number; + fileData: number[][] | null; // [channel][sample] + fileName: string | null; +} + +export const DEFAULT_CONFIG: VirtualEegConfig = { + channels: 4, + sampleRate: 256, + template: "good_quality", + quality: "good", + amplitudeUv: 50, + noiseUv: 5, + lineNoise: "none", + dropoutProb: 0, + fileData: null, + fileName: null, +}; + +export const QUALITY_SNR: Record = { + poor: 0.5, + fair: 2, + good: 5, + excellent: 20, +}; + +export const CHANNEL_PRESETS: Record = { + 1: ["Fp1"], + 2: ["Fp1", "Fp2"], + 4: ["TP9", "AF7", "AF8", "TP10"], + 8: ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "O1", "O2"], + 16: ["Fp1", "Fp2", "F7", "F3", "Fz", "F4", "F8", "T3", "C3", "Cz", "C4", "T4", "P3", "Pz", "P4", "O1"], + 32: [ + "Fp1", + "Fp2", + "AF3", + "AF4", + "F7", + "F3", + "Fz", + "F4", + "F8", + "FT7", + "FC3", + "FCz", + "FC4", + "FT8", + "T7", + "C3", + "Cz", + "C4", + "T8", + "TP7", + "CP3", + "CPz", + "CP4", + "TP8", + "P7", + "P3", + "Pz", + "P4", + "P8", + "O1", + "Oz", + "O2", + ], +}; + +/** Get channel labels for a given channel count. */ +export function getChannelLabels(n: number): string[] { + if (n in CHANNEL_PRESETS) return CHANNEL_PRESETS[n]; + return Array.from({ length: n }, (_, i) => `Ch${i + 1}`); +} + +/** Gaussian random using Box-Muller transform. */ +function gaussianRandom(): number { + const u1 = Math.random(); + const u2 = Math.random(); + return Math.sqrt(-2 * Math.log(u1 || 1e-10)) * Math.cos(2 * Math.PI * u2); +} + +/** Generate a single sample buffer for all channels at a given time. */ +export function generateSamples(config: VirtualEegConfig, sampleIndex: number): number[] { + const { channels, sampleRate, template, amplitudeUv, noiseUv, lineNoise, dropoutProb } = config; + const snr = QUALITY_SNR[config.quality]; + const t = sampleIndex / sampleRate; + + // Check for dropout + if (dropoutProb > 0 && Math.random() < dropoutProb / sampleRate) { + return new Array(channels).fill(0); + } + + const samples = new Array(channels); + + for (let ch = 0; ch < channels; ch++) { + let value = 0; + const phaseOffset = ch * 0.3; + + switch (template) { + case "sine": { + // Standard EEG frequency bands + const delta = Math.sin(2 * Math.PI * 2 * t + phaseOffset) * 0.4; + const theta = Math.sin(2 * Math.PI * 6 * t + phaseOffset) * 0.3; + const alpha = Math.sin(2 * Math.PI * 10 * t + phaseOffset) * 0.5; + const beta = Math.sin(2 * Math.PI * 20 * t + phaseOffset) * 0.2; + const gamma = Math.sin(2 * Math.PI * 40 * t + phaseOffset) * 0.1; + value = (delta + theta + alpha + beta + gamma) * amplitudeUv; + break; + } + + case "good_quality": { + // Dominant alpha rhythm (8–13 Hz) + pink noise background + const alpha = Math.sin(2 * Math.PI * (10 + ch * 0.1) * t + phaseOffset) * 0.6; + const theta = Math.sin(2 * Math.PI * 5.5 * t + phaseOffset) * 0.15; + const beta = Math.sin(2 * Math.PI * 18 * t + phaseOffset) * 0.1; + // Pink noise approximation + const pink = (gaussianRandom() * 0.15) / (1 + Math.abs(gaussianRandom()) * 0.5); + value = ((alpha + theta + beta + pink) * amplitudeUv * snr) / 5; + break; + } + + case "bad_quality": { + // Muscle artefacts + electrode pops + const alpha = Math.sin(2 * Math.PI * 10 * t + phaseOffset) * 0.2; + const muscle = gaussianRandom() * 0.6; // High-frequency muscle + const pop = Math.random() < 0.005 ? gaussianRandom() * 5 : 0; // Electrode pops + const drift = Math.sin(2 * Math.PI * 0.1 * t + ch) * 2; // Slow drift + value = (alpha + muscle + pop + drift) * amplitudeUv * 0.3; + break; + } + + case "interruptions": { + // Good signal with periodic 1–3s dropouts every 8–15s + const cycleLen = 10 + ch * 0.5; + const dropoutLen = 1.5; + const inDropout = t % cycleLen > cycleLen - dropoutLen; + if (inDropout) { + value = gaussianRandom() * noiseUv * 0.1; + } else { + const alpha = Math.sin(2 * Math.PI * 10 * t + phaseOffset) * 0.5; + const theta = Math.sin(2 * Math.PI * 6 * t + phaseOffset) * 0.2; + value = ((alpha + theta) * amplitudeUv * snr) / 5; + } + break; + } + + case "file": { + if (config.fileData?.[ch]) { + const data = config.fileData[ch]; + value = data[sampleIndex % data.length] ?? 0; + } + break; + } + } + + // Add noise + if (template !== "file") { + value += gaussianRandom() * noiseUv; + } + + // Add line noise + if (lineNoise === "50hz") { + value += Math.sin(2 * Math.PI * 50 * t) * noiseUv * 0.3; + } else if (lineNoise === "60hz") { + value += Math.sin(2 * Math.PI * 60 * t) * noiseUv * 0.3; + } + + samples[ch] = value; + } + + return samples; +} + +// ── Runtime ──────────────────────────────────────────────────────────────── + +export interface VirtualEegRuntime { + config: VirtualEegConfig; + running: boolean; + sampleIndex: number; + timer: ReturnType | null; + onSamples: ((electrode: number, samples: number[], timestamp: number) => void) | null; +} + +export function createRuntime(config: VirtualEegConfig): VirtualEegRuntime { + return { + config: { ...config }, + running: false, + sampleIndex: 0, + timer: null, + onSamples: null, + }; +} + +const BATCH_SIZE = 8; // samples per callback + +export function startRuntime(rt: VirtualEegRuntime): void { + if (rt.running) return; + rt.running = true; + const intervalMs = (1000 * BATCH_SIZE) / rt.config.sampleRate; + + rt.timer = setInterval(() => { + const timestamp = Date.now() / 1000; + for (let ch = 0; ch < rt.config.channels; ch++) { + const samples: number[] = []; + for (let i = 0; i < BATCH_SIZE; i++) { + const allCh = generateSamples(rt.config, rt.sampleIndex + i); + samples.push(allCh[ch]); + } + rt.onSamples?.(ch, samples, timestamp); + } + rt.sampleIndex += BATCH_SIZE; + }, intervalMs); +} + +export function stopRuntime(rt: VirtualEegRuntime): void { + if (!rt.running) return; + rt.running = false; + if (rt.timer) { + clearInterval(rt.timer); + rt.timer = null; + } +} + +// ── Band power estimation (simple FFT-free approximation) ────────────────── + +/** Estimate band powers from recent samples using sine-component energy. */ +export function estimateBandPowers(config: VirtualEegConfig, sampleIndex: number): Record { + const t = sampleIndex / config.sampleRate; + const snr = QUALITY_SNR[config.quality]; + const amp = config.amplitudeUv; + + // Approximate band energies based on the template + let delta = 0, + theta = 0, + alpha = 0, + beta = 0, + gamma = 0; + + switch (config.template) { + case "sine": + delta = 0.4 * amp; + theta = 0.3 * amp; + alpha = 0.5 * amp; + beta = 0.2 * amp; + gamma = 0.1 * amp; + break; + case "good_quality": + delta = 0.1 * amp; + theta = 0.15 * amp; + alpha = (0.6 * amp * snr) / 5; + beta = 0.1 * amp; + gamma = 0.05 * amp; + break; + case "bad_quality": + delta = 0.3 * amp; + theta = 0.2 * amp; + alpha = 0.1 * amp; + beta = 0.5 * amp; + gamma = 0.4 * amp; // muscle artefacts + break; + case "interruptions": { + const cycleLen = 10; + const inDropout = t % cycleLen > cycleLen - 1.5; + if (inDropout) { + delta = theta = alpha = beta = gamma = 0.01 * amp; + } else { + delta = 0.1 * amp; + theta = 0.2 * amp; + alpha = 0.5 * amp; + beta = 0.1 * amp; + gamma = 0.05 * amp; + } + break; + } + default: + delta = theta = alpha = beta = gamma = 0.1 * amp; + } + + // Add some noise-driven variation + const jitter = () => 1 + (Math.random() - 0.5) * 0.2; + return { + delta: delta * jitter(), + theta: theta * jitter(), + alpha: alpha * jitter(), + beta: beta * jitter(), + gamma: gamma * jitter(), + }; +} diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 000000000..b4dc08d63 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,35 @@ + + + + + +
+
+

+ {$page.error?.message ?? t("common.error")} +

+

+ {t("error.description")} +

+
+ + {t("error.goHome")} + + +
+ {#if $page.status && $page.status !== 500} +

+ HTTP {$page.status} +

+ {/if} +
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ae099a84e..6ccb72d3e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,57 +5,98 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. -->
+ -
- {@render children()} + +
+
+ {@render children()} +
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5af949e05..1aeb96860 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5,738 +5,1642 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> -
- -
+
+ +
+ + {#if status.state === "connected" && (status.device_kind === "iroh-remote" || status.iroh_client_name || status.phone_info)} + {@const pi = status.phone_info} +
+
+ {#if phoneBannerImage((pi ?? null) as Record | null, status.device_name, status.device_kind)} + | null, status.device_name, status.device_kind) ?? ""} alt="remote client" + class="w-7 h-7 rounded-lg shrink-0 object-cover border border-indigo-400/30" /> + {:else} + +
+ + + + + +
+ {/if} + +
+
+ + {status.device_name ?? status.iroh_client_name ?? "Remote device"} + + + + + +
+ + streamed from {pi?.phone_marketing_name ?? pi?.phone_name ?? status.iroh_client_name ?? "remote client"} + {#if pi?.os} · {pi.os}{#if pi?.os_version} {pi.os_version}{/if}{/if} + {#if pi?.app_version} · v{pi.app_version}{/if} + {#if status.iroh_eeg_streaming_active} · EEG live{:else if status.iroh_streaming_active} · link live{/if} + +
+ + {#if pi?.battery_level != null && (pi?.battery_level ?? 0) > 0} +
+ 📱 + + {Math.round((pi?.battery_level ?? 0) * 100)}% + +
+ {/if} +
+
+ {/if} + {#if modelDlVisible} -
+
{#if modelDl.downloading_weights}
- + {t("model.downloading")} {#if modelDl.download_status_msg} - + {modelDl.download_status_msg} {/if}
@@ -767,21 +1671,21 @@ the Free Software Foundation, version 3 only. --> px-3 py-2.5">
- + {t("model.autoRetryIn", { secs: String(modelDl.download_retry_in_secs) })} · {t("model.autoRetryAttempt", { n: String(modelDl.download_retry_attempt + 1) })} {#if modelDl.download_status_msg && modelDl.download_status_msg !== "Download cancelled."} - + {modelDl.download_status_msg} {/if}
@@ -790,9 +1694,9 @@ the Free Software Foundation, version 3 only. -->
{/if} - + bg-surface-1"> {#if status.state === "scanning"}{t("dashboard.scanning")} + {:else if status.state === "connecting"}{t("dashboard.scanning")} {:else if status.state === "connected"}● {t("dashboard.connected")} {:else if status.state === "bt_off"}⚠ {t("dashboard.btOff")} {:else}{t("dashboard.disconnected")}{/if} @@ -826,33 +1731,28 @@ the Free Software Foundation, version 3 only. --> /> {/if} {#if status.device_name && status.state === "connected"} - {status.device_name} +
+
+ {status.device_name} + {#if sourceLabel} + {sourceLabel} + {/if} + {#if hasSecondary} + {t("dashboard.primary")} + +{secondarySessions.length} + {/if} +
+ {#if connectedDeviceId} +
{connectedDeviceId}
+ {/if} +
{:else} {/if} - +
- - - - -
{:else} - +
- - - -
+ -
+
@@ -915,10 +1793,10 @@ the Free Software Foundation, version 3 only. --> - {#if status.state === "scanning"} + {#if status.state === "scanning" || status.state === "connecting"} {t("dashboard.scanning")} {:else if status.state === "connected"} ● {t("dashboard.connected")} @@ -939,14 +1817,29 @@ the Free Software Foundation, version 3 only. --> {/if} {#if status.device_name && status.state === "connected"} -

{status.device_name}

- {#if status.serial_number || status.mac_address} +
+

+ {status.device_name} + {#if sourceLabel} + {sourceLabel} + {/if} + {#if hasSecondary} + {t("dashboard.primary")} + {/if} +

+ {#if connectedDeviceId} +

{connectedDeviceId}

+ {/if} +
+ {#if status.serial_number || status.mac_address || status.firmware_version}
{#if status.serial_number} @@ -954,14 +1847,74 @@ the Free Software Foundation, version 3 only. --> {#if status.mac_address} {/if} + {#if status.firmware_version} + + fw {status.firmware_version} + + {/if}
{/if} + + + {#if status.paired_devices.length > 1} + {#if showDeviceSwitcher} +
+ {#each status.paired_devices.filter(d => d.id !== status.device_id) as dev} + + {/each} + +
+ {:else} + + {/if} + {/if} + + + {/if}
{/if} @@ -972,6 +1925,16 @@ the Free Software Foundation, version 3 only. --> + {#if status.iroh_tunnel_online && status.state !== "connected"} +
+

+ iroh online ({status.iroh_connected_peers ?? 1} peer{(status.iroh_connected_peers ?? 1) !== 1 ? "s" : ""}) + {#if status.iroh_remote_device_connected} · iOS BLE connected{:else} · waiting for iOS BLE{/if} + {#if status.iroh_eeg_streaming_active} · EEG live{:else if status.iroh_streaming_active} · link live{:else} · idle{/if} +

+
+ {/if} + {#if status.state === "bt_off"}
-

+

{t("dashboard.bluetoothIsOff")}

-

+

{t("dashboard.turnOnBluetooth")}

@@ -1014,13 +1977,13 @@ the Free Software Foundation, version 3 only. -->
- {:else if status.bt_error && status.state === "disconnected"} - {#if status.bt_error === "NO_MUSE_NEARBY"} + {:else if status.device_error && status.state === "disconnected"} + {#if status.device_error === "NO_MUSE_NEARBY"}
-

+

{t("dashboard.noMuseNearbyTitle")}

-
    +
    • • {t("dashboard.noMuseNearbyHint1")}
    • • {t("dashboard.noMuseNearbyHint2")}
    • • {t("dashboard.noMuseNearbyHint3")}
    • @@ -1030,9 +1993,25 @@ the Free Software Foundation, version 3 only. -->
+ {:else if status.device_error.includes("EEG stream not available") || status.device_error.includes("-32230")} +
+

+ EEG Access Not Available +

+

+ Your Emotiv Cortex App does not have raw EEG data access enabled for this headset. + To stream EEG, enable the Raw EEG data stream in your Cortex App settings. +

+
+ + +
+
{:else}
-
{status.bt_error}
+
{status.device_error}
@@ -1041,7 +2020,7 @@ the Free Software Foundation, version 3 only. --> {/if} - {:else if status.state === "scanning"} + {:else if status.state === "scanning" || status.state === "connecting" || status.retry_countdown_secs > 0}
{#if status.retry_countdown_secs > 0} @@ -1061,11 +2040,11 @@ the Free Software Foundation, version 3 only. --> {status.retry_countdown_secs}
-

+

{t("dashboard.retryCountdown", { secs: String(status.retry_countdown_secs) })}

{#if status.retry_attempt > 0} -

+

{t("dashboard.retryAttempt", { n: String(status.retry_attempt) })}

{/if} @@ -1076,126 +2055,267 @@ the Free Software Foundation, version 3 only. --> {:else} -

- {status.target_name ? t("dashboard.connectingTo", { name: status.target_name }) : (isGanglion ? t("dashboard.lookingForGanglion") : t("dashboard.lookingForMuse"))} -

+ {#if connectingTarget} +
+

+ {t("dashboard.connectingTo", { name: connectingTarget.name })} +

+ {#if connectingTarget.id} +

{connectingTarget.id}

+ {/if} + {#if (status.target_id?.startsWith("peer:") || status.target_name?.startsWith("peer:") || status.iroh_tunnel_online) && !status.device_name} +

+ {#if status.iroh_tunnel_online} + iroh tunnel online{#if status.iroh_client_name} ({status.iroh_client_name}){/if} + {:else} + iroh tunnel offline + {/if} + {#if status.iroh_remote_device_connected} + · iOS BLE connected + {:else} + · waiting for iOS BLE + {/if} + {#if status.iroh_eeg_streaming_active} + · EEG live + {:else if status.iroh_streaming_active} + · link live, EEG paused + {:else} + · no live stream yet + {/if} +

+ {/if} +
+ {:else} +

+ {isGanglion ? t("dashboard.lookingForGanglion") : isEmotiv ? t("dashboard.connectingEmotiv") : isMw75 ? t("dashboard.connectingTo", { name: "MW75 Neuro" }) : isHermes ? t("dashboard.connectingTo", { name: "Hermes" }) : isIdun ? t("dashboard.connectingTo", { name: "IDUN Guardian" }) : isMendi ? t("dashboard.connectingTo", { name: "Mendi" }) : t("dashboard.lookingForMuse")} +

+ {/if} + + + + {#if status.paired_devices.length > 1 || (status.paired_devices.length > 0 && !status.target_id)} +
+

+ {t("dashboard.connectDifferent")} +

+
+ {#each status.paired_devices.filter(d => d.id !== status.target_id) as dev} + + {/each} +
+
+ {/if} {/if}
{:else if status.state === "connected"} - +
+ + {#if hasBattery}
- {t("dashboard.battery")} + {t("dashboard.battery")} - + {(status.battery ?? 0).toFixed(0)}% {#if status.temperature_raw > 0} - + 🌡 {status.temperature_raw} {/if}
{/if} - - {#if isGanglion} + + {#if chLabels.length > 0 || sourceLabel} + {@const sr = (status.eeg_sample_rate_hz ?? 0) > 0 ? Math.round(status.eeg_sample_rate_hz ?? 0) : null}
- - OpenBCI Ganglion · 4ch · 200 Hz + + {chLabels.length}ch{#if sr} · {sr} Hz{/if}{#if sourceLabel} · {sourceLabel}{/if}
{/if} - -
- - {t("dashboard.signal")} - - {#each EEG_CH as ch, i} - {@const q = status.channel_quality[i] ?? 'no_signal'} -
- - - {#if q === 'fair' || q === 'poor'} - - {/if} - + {#if hasEeg} + + {@const visibleQ = status.channel_quality.slice(0, chLabels.length)} + {@const qGood = visibleQ.filter(q => q === 'good').length} + {@const qFair = visibleQ.filter(q => q === 'fair').length} + {@const qPoor = visibleQ.filter(q => q === 'poor').length} + {@const qNone = chLabels.length - qGood - qFair - qPoor} +
+
- {/each} -
- - - - - {#if showElectrodes} -
- + + {#if signalExpanded} +
4 && chLabels.length <= 8} class:grid-cols-4={chLabels.length > 8}> + {#each chLabels as ch, i} + {@const q = status.channel_quality[i] ?? 'no_signal'} +
+ + + {#if q === 'fair' || q === 'poor'} + + {/if} + + + {ch} + {qualityLabel(q)} +
+ {/each} +
+ {/if}
- {/if} - -
- -

- {t("disclaimer.exgPlacement")} -

-
+ + - - + {#if showElectrodes} +
+ +
+ {/if} - - + +
+ +

+ {t("disclaimer.exgPlacement")} +

+
+ {:else} +
+ Modalities + {#if hasFnirs && fnirsLabels.length > 0} +
fNIRS: {fnirsLabels.join(" · ")}
+
+
+
Oxy
+
{(status.fnirs_oxygenation_pct ?? 0).toFixed(1)}%
+
+
+
Workload
+
{(status.fnirs_workload ?? 0).toFixed(1)}
+
+
+
Lat
+
{(status.fnirs_lateralization ?? 0).toFixed(1)}
+
+
+
ΔHbO
+
{((((status.fnirs_hbo_left ?? 0) + (status.fnirs_hbo_right ?? 0)) / 2)).toFixed(3)}
+
+
+
ΔHbR
+
{((((status.fnirs_hbr_left ?? 0) + (status.fnirs_hbr_right ?? 0)) / 2)).toFixed(3)}
+
+
+
Conn
+
{(status.fnirs_connectivity ?? 0).toFixed(3)}
+
+
+ {/if} + {#if ppgLabels.length > 0} +
PPG: {ppgLabels.join(" · ")}
+ {/if} + {#if imuLabels.length > 0} +
IMU: {imuLabels.join(" · ")}
+ {/if} +
+ {#if hasFnirs} +
+ +
+ {/if} + {/if} - - + {#if hasEeg} + + + + + + + + + + +
+ +
- - + + - - + + - - + + + {/if} - + + {#if hasImuCap} + {/if} {#if hasPpg && (hrScore > 0 || status.ppg_sample_count > 0)} @@ -1204,10 +2324,10 @@ the Free Software Foundation, version 3 only. --> perfIdx={perfIdxScore} stressIdx={stressIdxScore} /> {/if} - + {#if hasPpg && (status.ppg_sample_count > 0 || status.state === "connected")}
+ bg-muted dark:bg-surface-2 px-3 py-2.5 flex flex-col gap-1.5"> @@ -1235,10 +2355,11 @@ the Free Software Foundation, version 3 only. -->
{/if} - - {@const hasImu = status.accel.some(v => v !== 0) || status.gyro.some(v => v !== 0)} + + {#if hasImuCap} + {@const hasImuData = status.accel.some(v => v !== 0) || status.gyro.some(v => v !== 0)}
+ bg-muted dark:bg-surface-2 px-3 py-2.5 flex flex-col gap-1.5"> {#if imuExpanded} {/if}
+ {/if} - -
- - {#if eegChExpanded} -
- {#each EEG_CH as ch, i} -
- {ch} - {fmtEeg(status.eeg[i])} -
- {/each} -
- {/if} -
+ {#if hasEeg} + +
+ + {#if eegChExpanded} +
4 && chLabels.length <= 8} class:grid-cols-4={chLabels.length > 8}> + {#each chLabels as ch, i} +
+ {ch} + {fmtEeg(status.eeg[i])} +
+ {/each} +
+ {/if} +
+ {/if}
- + bg-muted dark:bg-surface-2 px-3 py-2 flex items-center gap-2.5"> + {t("dashboard.dailyGoal")}
- {Math.floor(todayTotalSecs / 60)}m / {dailyGoalMin}m {#if goalReached} - 🎯 + 🎯 {/if}
@@ -1322,9 +2446,9 @@ the Free Software Foundation, version 3 only. --> [t("dashboard.todayTotal"), fmtUptime(todayTotalSecs)], ] as [label, val]}
- {label} - {val} + bg-muted dark:bg-surface-2 px-2 py-1.5 flex flex-col gap-0.5"> + {label} + {val}
{/each}
@@ -1333,9 +2457,9 @@ the Free Software Foundation, version 3 only. --> {#if status.csv_path}
- {t("dashboard.rec")} - + bg-muted dark:bg-surface-3 px-3 py-2"> + {t("dashboard.rec")} + {csvName(status.csv_path)}
@@ -1343,14 +2467,14 @@ the Free Software Foundation, version 3 only. --> {#if recentLabel}
+ rounded-md border border-primary/25 bg-violet-500/8 dark:bg-violet-500/10"> + class="w-2.5 h-2.5 shrink-0 text-violet-600 dark:text-violet-400"> - + {recentLabel} +
+ { onboardDone = { devicePaired: true, calibrated: true, firstSession: true, goalSet: true, llmDownloaded: true, searchRun: true, dndConfigured: true, apiVisited: true }; saveOnboarding(); }} + />
{/if} +
+ {:else} {#if status.paired_devices.length > 0} -

{t("dashboard.pairedDevices")}

+ {t("dashboard.pairedDevices")}
- {#each status.paired_devices as dev} + {#each status.paired_devices as dev (dev.id)}
+ bg-muted dark:bg-surface-2 px-3 py-2">
- {dev.name} - {dev.id} + {dev.name} + {dev.id}
+ @@ -1452,74 +2550,214 @@ the Free Software Foundation, version 3 only. -->
{/each}
- + {#if dashboardUnpaired.length > 0} +
+ {t("devices.discoveredDevices")} + {#each dashboardUnpaired.slice(0, 4) as dev (dev.id)} +
+
+ {dev.name} + {dev.id} +
+ +
+ {/each} +
+ {/if} +
+ + +
{:else}
-

+

{t("dashboard.noDevicesPaired")}

- + + {#if dashboardUnpaired.length > 0} +
+

+ {t("devices.discoveredDevices")} +

+ {#each dashboardUnpaired.slice(0, 5) as dev (dev.id)} +
+
+ {dev.name} + {dev.id} +
+ +
+ {/each} +
+ {/if} + +
+ + +
{/if} {/if} - + + {#if status.state === "connected" && hasEeg}
-

- {t("dashboard.bandPowers")} -

- {#if status.state === "connected"} - - {/if} + {t("dashboard.bandPowers")} + +
+
+
-
-
-

{t("dashboard.eegWaveforms")}

- {#if status.state === "connected"} - - {/if} + {t("dashboard.eegWaveforms")} +
- +
+ {/if} + + {#if hasSecondary} +
+
+ + {t("dashboard.backgroundRecordings")} + +
+ {#each secondarySessions as sess (sess.id)} +
+ + + + + + + +
+ + {sess.device_name} + + + {sess.device_kind === "lsl" ? "LSL" : sess.device_kind === "lsl-iroh" ? "iroh" : sess.device_kind.toUpperCase()} + +
+ + + + {sess.channels}ch · {sess.sample_rate % 1 === 0 ? sess.sample_rate : sess.sample_rate.toFixed(1)} Hz + + + {sess.sample_count.toLocaleString()} + + + + +
+ {/each} +
+ {/if} + -

+

{#if status.state === "connected"} {t("dashboard.streamingCsv")} {:else if status.state === "scanning"} - {t("dashboard.cancelViaTray")} + {t("dashboard.scanningFooter")} {/if}

- {#if status.state === "connected"} - - {/if} - v{appVersion} + + e.key === 'Enter' && toggleEnginePopover()} role="button" tabindex="0"> + + {#if daemonStatus.state === "error" || daemonStatus.state === "disconnected"} + {#if daemonLaunching} + {t("daemon.starting")} + {:else if daemonCountdown > 0} + + {:else} + + {/if} + {:else} + + {engineState === "streaming" ? t("daemon.streaming") : engineState === "idle" ? t("daemon.idle") : t("daemon.stateConnecting")} + {#if daemonStatus.latencyMs != null} + {daemonStatus.latencyMs}ms + {/if} + + {/if} + + + {#if enginePopoverOpen} +
+
{t("daemon.popoverTitle")}
+
+ {t("daemon.uptime")} + {engineUptime()} + {#if daemonStatus.version} + {t("daemon.version")} + {daemonStatus.version} + {/if} + {t("daemon.latency")} + {daemonStatus.latencyMs != null ? `${daemonStatus.latencyMs}ms` : "—"} + {t("daemon.wsClients")} + {engineWsClients} +
+ +
+ {/if} +
+ v{appVersion}
+
+ +{@render children()} diff --git a/src/routes/chat/+page.svelte b/src/routes/chat/+page.svelte new file mode 100644 index 000000000..7444a0d2f --- /dev/null +++ b/src/routes/chat/+page.svelte @@ -0,0 +1,1677 @@ + + + + + + +
+ + + {#if sidebarOpen} + + {/if} + + +
+ + sidebarOpen = !sidebarOpen} + onToggleSettings={() => { showSettings = !showSettings; if (showSettings) showTools = false; }} + onToggleTools={() => { showTools = !showTools; if (showTools) showSettings = false; }} + onStartServer={startServer} + onStopServer={stopServer} + onNewChat={newChat} + onToggleEeg={() => eegContext = !eegContext} + onToggleContextBreakdown={() => showContextBreakdown = !showContextBreakdown} + /> + + {#if startError} +
+ + + + {startError} + +
+ {/if} + + {#if showContextBreakdown && nCtx > 0} + 0} + onClose={() => showContextBreakdown = false} + onViewFull={() => { showContextBreakdown = false; showContextViewer = true; }} + /> + {/if} + + {#if showContextViewer} + showContextViewer = false} + /> + {/if} + + +
+ {#if showSettings} +
+ +
+ {/if} + + {#if showTools} +
+ +
+ {/if} + + + + {#if showVoice} +
+ asrError = ""} + /> +
+ {/if} + + +
+ +
+
diff --git a/src/routes/compare/+page.svelte b/src/routes/compare/+page.svelte index 20e5084d1..e719cbfdf 100644 --- a/src/routes/compare/+page.svelte +++ b/src/routes/compare/+page.svelte @@ -7,1596 +7,992 @@ the Free Software Foundation, version 3 only. --> -
- - -
- - - - - - {t("compare.title")} - - - - -
+
-
+
{#if loading}
-

{t("common.loading")}

+

{t("common.loading")}

{:else if sessions.length === 0} @@ -1607,8 +1003,8 @@ the Free Software Foundation, version 3 only. --> -

{t("compare.needSessions")}

-

+

{t("compare.needSessions")}

+

{t("compare.needSessionsHint")}

@@ -1704,7 +1122,7 @@ the Free Software Foundation, version 3 only. --> {/each} @@ -1807,9 +1225,9 @@ the Free Software Foundation, version 3 only. -->
{#if rw > 8} - {utcToTimeStr(rangeStart)} - {utcToTimeStr(rangeEnd)} {/if}
@@ -1820,25 +1238,27 @@ the Free Software Foundation, version 3 only. --> {#if anchorUtc !== null}
- {t("compare.timeFrom")} + {t("compare.timeFrom")} setRangeStart(side, (e.target as HTMLInputElement).value)} class="flex-1 min-w-0 rounded border border-border dark:border-white/[0.08] - bg-background dark:bg-[#14141e] px-1.5 py-0.5 - text-[0.6rem] text-foreground focus:outline-none focus:ring-1" + bg-background dark:bg-surface-1 px-1.5 py-0.5 + text-ui-sm text-foreground focus:outline-none focus:ring-1" style="--tw-ring-color:{accent}50"/> - + setRangeEnd(side, (e.target as HTMLInputElement).value)} class="flex-1 min-w-0 rounded border border-border dark:border-white/[0.08] - bg-background dark:bg-[#14141e] px-1.5 py-0.5 - text-[0.6rem] text-foreground focus:outline-none focus:ring-1" + bg-background dark:bg-surface-1 px-1.5 py-0.5 + text-ui-sm text-foreground focus:outline-none focus:ring-1" style="--tw-ring-color:{accent}50"/>
{/if} @@ -1849,7 +1269,7 @@ the Free Software Foundation, version 3 only. --> {#if aDayStr !== null && bDayStr !== null && aDayStr === bDayStr}
+ px-3 py-2 text-ui-sm text-amber-600 dark:text-amber-400"> @@ -1868,7 +1288,7 @@ the Free Software Foundation, version 3 only. -->
{#if metricsA && metricsB} - + {t("compare.epochsA", { n: metricsA.n_epochs })} · {t("compare.epochsB", { n: metricsB.n_epochs })} {/if} @@ -1913,12 +1331,22 @@ the Free Software Foundation, version 3 only. -->
+ {#if comparing && !metricsA && !metricsB} + + +
+ + + +

{t("compare.comparing")}

+
+ {/if} {#if metricsA && metricsB}
- + {t("compare.spectrum")} @@ -1927,7 +1355,7 @@ the Free Software Foundation, version 3 only. --> {#each bandMeta as band, i}
- {band.sym} {band.name} + {band.sym} {band.name}
{/each}
@@ -1935,37 +1363,37 @@ the Free Software Foundation, version 3 only. -->
- A + A
{#if metricsA.n_epochs === 0} - {t("compare.noEpochs")} + {t("compare.noEpochs")} {/if}
- B + B
{#if metricsB.n_epochs === 0} - {t("compare.noEpochs")} + {t("compare.noEpochs")} {/if}
- + {t("compare.bandPowers")}
+ bg-surface-1 overflow-hidden p-2"> @@ -1973,30 +1401,30 @@ the Free Software Foundation, version 3 only. -->
+ bg-surface-1 overflow-hidden">
- {t("compare.band")} - A - B - {t("compare.diff")} + {t("compare.band")} + A + B + {t("compare.diff")}
{#each bandKeys as key, i} {@const a = bv(metricsA, key)} {@const b = bv(metricsB, key)}
- {bandMeta[i].name} - {bandMeta[i].sym} + {bandMeta[i].name} + {bandMeta[i].sym}
- {pct(a)}% - {pct(b)}% - {diff(a, b)} + {pct(a)}% + {pct(b)}% + {diff(a, b)}
{/each}
@@ -2004,38 +1432,38 @@ the Free Software Foundation, version 3 only. -->
- + {t("compare.scores")}
- {#each scoreKeys as sk} + {#each scoreKeys as sk (sk)} {@const a = metricsA[sk.key]} {@const b = metricsB[sk.key]}
- + bg-surface-1 px-3 py-2.5 flex flex-col gap-2"> + {t(sk.label)}
- A + A
- {a.toFixed(0)} + {a.toFixed(0)}
- B + B
- {b.toFixed(0)} + {b.toFixed(0)}
- + {scoreDiff(a, b)}
@@ -2045,15 +1473,15 @@ the Free Software Foundation, version 3 only. -->
- + {t("compare.radarChart")}
+ bg-surface-1 overflow-hidden p-2"> -
+
A @@ -2069,20 +1497,20 @@ the Free Software Foundation, version 3 only. --> {#if improved.length > 0 || declined.length > 0}
- + {t("compare.insights")}
+ bg-surface-1 px-3.5 py-3 flex flex-col gap-2.5"> {#if improved.length > 0}
- ▲ Improved + ▲ Improved {#each improved as d} + text-ui-xs font-medium"> {d.label} - + {d.pctChange > 0 ? "+" : ""}{d.pctChange.toFixed(0)}% @@ -2091,20 +1519,20 @@ the Free Software Foundation, version 3 only. --> {/if} {#if declined.length > 0}
- ▼ Declined + ▼ Declined {#each declined as d} + text-ui-xs font-medium"> {d.label} - + {d.pctChange > 0 ? "+" : ""}{d.pctChange.toFixed(0)}% {/each}
{/if} -

+

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

@@ -2113,11 +1541,11 @@ the Free Software Foundation, version 3 only. -->
- + {t("dashboard.faa")}
+ bg-surface-1 px-3.5 py-3 flex flex-col gap-3"> {#each [ { label: "A", val: metricsA.faa }, @@ -2125,21 +1553,21 @@ the Free Software Foundation, version 3 only. --> ] as item}
- {item.label} + {item.label}
{#if item.val >= 0} -
{:else} -
{/if}
- + {item.val >= 0 ? "+" : ""}{item.val.toFixed(3)}
@@ -2147,12 +1575,12 @@ the Free Software Foundation, version 3 only. --> {/each}
-
+
{t("dashboard.faaWithdrawal")} {t("dashboard.faaFormula")} {t("dashboard.faaApproach")}
- + {(metricsA.faa - metricsB.faa) >= 0 ? "+" : ""}{(metricsA.faa - metricsB.faa).toFixed(3)}
@@ -2169,36 +1597,36 @@ the Free Software Foundation, version 3 only. --> transition-transform duration-150 shrink-0 {advExpanded ? 'rotate-90' : ''}"> - {t("compare.advancedMetrics")} {#if advExpanded}
+ bg-surface-1 overflow-hidden">
- {t("compare.metric")} - A - B - {t("compare.diff")} + {t("compare.metric")} + A + B + {t("compare.diff")}
{#each advancedMetrics as mr} {@const a = Number(metricsA[mr.key]) || 0} {@const b = Number(metricsB[mr.key]) || 0} {@const d = a - b}
- - {t(mr.label)}{#if mr.unit} ({mr.unit}){/if} + + {t(mr.label)}{#if mr.unit} ({mr.unit}){/if} - {mr.fmt(a)} - {mr.fmt(b)} - + {mr.fmt(a)} + {mr.fmt(b)} + {Math.abs(d) < 0.001 ? "—" : `${d > 0 ? "+" : ""}${mr.fmt(d)}`}
@@ -2211,10 +1639,10 @@ the Free Software Foundation, version 3 only. --> {#if tsA.length > 2 || tsB.length > 2}
- + {t("compare.heatmap")} - @@ -2225,16 +1653,16 @@ the Free Software Foundation, version 3 only. --> {#if tsA.length > 2}
- A - {t("compare.heatmapBands")} + A + {t("compare.heatmapBands")}
+ bg-surface-3 overflow-hidden">
- + {t("compare.heatmapRowNorm")} · {tsA.length} epochs
@@ -2244,16 +1672,16 @@ the Free Software Foundation, version 3 only. --> {#if tsB.length > 2}
- B - {t("compare.heatmapBands")} + B + {t("compare.heatmapBands")}
+ bg-surface-3 overflow-hidden">
- + {t("compare.heatmapRowNorm")} · {tsB.length} epochs
@@ -2262,14 +1690,14 @@ the Free Software Foundation, version 3 only. --> {#if tsA.length > 2 && tsB.length > 2}
- {t("compare.heatmapDiff")} + {t("compare.heatmapDiff")}
+ bg-surface-3 overflow-hidden">
- + {t("compare.heatmapDiffLegend")} · time-proportionally aligned
@@ -2279,11 +1707,11 @@ the Free Software Foundation, version 3 only. --> {#if tsA.length > 2}
- A - {t("compare.heatmapScores")} + A + {t("compare.heatmapScores")}
+ bg-surface-3 overflow-hidden"> @@ -2295,16 +1723,16 @@ the Free Software Foundation, version 3 only. --> {#if tsB.length > 2}
- B - {t("compare.heatmapScores")} + B + {t("compare.heatmapScores")}
+ bg-surface-3 overflow-hidden">
- + {t("compare.heatmapRowNorm")}
@@ -2317,10 +1745,10 @@ the Free Software Foundation, version 3 only. --> {#if tsA.length > 2 || tsB.length > 2}
- + {t("compare.timeSeries")} - @@ -2348,7 +1776,7 @@ the Free Software Foundation, version 3 only. --> {#if tsA.length > 2} - A — {t("compare.chartBands")} + A — {t("compare.chartBands")} ]} /> {/if} {#if tsB.length > 2} - B — {t("compare.chartBands")} + B — {t("compare.chartBands")} {#if tsA.length > 2} - A — {t("compare.chartScores")} + A — {t("compare.chartScores")} ]} /> {/if} {#if tsB.length > 2} - B — {t("compare.chartScores")} + B — {t("compare.chartScores")} {#if tsA.length > 2} - A — {t("compare.chartComposite")} + A — {t("compare.chartComposite")} ]} /> {/if} {#if tsB.length > 2} - B — {t("compare.chartComposite")} + B — {t("compare.chartComposite")} {#if tsA.some(r => r.hr > 0)} - A — {t("compare.chartPpg")} + A — {t("compare.chartPpg")} ]} /> {/if} {#if tsB.some(r => r.hr > 0)} - B — {t("compare.chartPpg")} + B — {t("compare.chartPpg")} {#if tsA.some(r => r.blink_r > 0)} - A — {t("compare.chartArtifacts")} + A — {t("compare.chartArtifacts")} ]} /> {/if} {#if tsB.some(r => r.blink_r > 0)} - B — {t("compare.chartArtifacts")} + B — {t("compare.chartArtifacts")} {#if tsA.some(r => r.still > 0)} - A — {t("compare.chartPose")} + A — {t("compare.chartPose")} ]} /> {/if} {#if tsB.some(r => r.still > 0)} - B — {t("compare.chartPose")} + B — {t("compare.chartPose")} {#if !umapRequested}
- + Brain Nebula™ - {t("compare.umap")} + {t("compare.umap")}
+ bg-surface-1 px-4 py-5 flex flex-col items-center gap-3"> @@ -2503,14 +1931,55 @@ the Free Software Foundation, version 3 only. --> -

+

{t("compare.umapDesc")}

+ {#if umapTotalEpochs > 0 && umapTotalEmb < 5} +

+ {t("compare.embeddingsNone")} +

+ {#if reembedRunning} + {@const eta = reembedEta()} +
+
+
+
+

+ {t("compare.embeddingsProcessing")} + {reembedStatus?.done ?? 0}/{reembedStatus?.total ?? 0} ({reembedPct}%) + {#if reembedStatus?.day} · {reembedStatus.day}{/if} + {#if eta} +
{eta.rate} embeddings/sec · ~{fmtDuration(eta.etaSecs)} remaining + {/if} +

+
+ {/if} + {:else if umapTotalEpochs > 0 && umapTotalEmb < umapTotalEpochs * 0.5} +

+ {umapTotalEmb}/{umapTotalEpochs} {t("compare.embeddingsReady")} ({Math.round(umapTotalEmb / umapTotalEpochs * 100)}%) +

+ {#if reembedRunning} + {@const eta = reembedEta()} +
+
+
+
+ {#if eta} + + {eta.rate}/sec · ~{fmtDuration(eta.etaSecs)} left + + {/if} +
+ {/if} + {/if}
{:else} @@ -2518,8 +1987,8 @@ the Free Software Foundation, version 3 only. --> style="min-height:calc(100vh - 8rem)">
- Brain Nebula™ - {t("compare.umap")} + Brain Nebula™ + {t("compare.umap")} {#if umapLoading} {#if umapQueuePosition !== null && umapQueuePosition > 0} @@ -2540,11 +2009,11 @@ the Free Software Foundation, version 3 only. -->
- + {umapQueuePosition} task{umapQueuePosition === 1 ? "" : "s"} running before yours - + {#if umapWaitSecs !== null && umapWaitSecs > 0} starts in ~{fmtSecs(umapWaitSecs)} {:else} @@ -2555,7 +2024,7 @@ the Free Software Foundation, version 3 only. -->
- + {fmtSecs(umapElapsed)} elapsed
@@ -2563,14 +2032,11 @@ the Free Software Foundation, version 3 only. --> {:else} - - computing 3D projection - {#if umapCountdown != null && umapCountdown > 0 && !umapProgress} - · ~{fmtSecs(umapCountdown)} remaining - {/if} - · {fmtSecs(umapElapsed)} elapsed + + computing 3D projection · {fmtSecs(umapElapsed)} elapsed {#if umapProgress && umapProgress.total_epochs > 0} + {@const pct = Math.round(umapProgress.epoch / umapProgress.total_epochs * 100)} {@const remEpochs = umapProgress.total_epochs - umapProgress.epoch} {@const remSecs = umapProgress.epoch_ms > 0 @@ -2581,30 +2047,45 @@ the Free Software Foundation, version 3 only. -->
- + {pct}%
{#if remSecs !== null} - + epoch {umapProgress.epoch}/{umapProgress.total_epochs} · {umapProgress.epoch_ms.toFixed(0)}ms/ep · ~{fmtSecs(remSecs)} left {/if}
+ {:else} + + {@const estSecs = umapOwnEstimateSecs > 0 ? umapOwnEstimateSecs : 15} + {@const estPct = Math.min(95, Math.round(umapElapsed / estSecs * 100))} +
+
+
+
+
+ + ~{fmtSecs(Math.max(0, estSecs - umapElapsed))} + +
+
{/if} {/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'}"> @@ -2615,18 +2096,49 @@ the Free Software Foundation, version 3 only. -->
-
- -
+ {#if umapError && !umapLoading} +
+ + + + +

{umapError}

+ {#if reembedRunning} + {@const eta = reembedEta()} +
+
+
+
+

+ {t("compare.embeddingsProcessing")} + {reembedStatus?.done ?? 0}/{reembedStatus?.total ?? 0} ({reembedPct}%) + {#if eta} +
{eta.rate} embeddings/sec · ~{fmtDuration(eta.etaSecs)} remaining + {/if} +

+
+ {:else} + + {/if} +
+ {:else} +
+ +
+ {/if} -
+
- + A ({(umapResult ?? umapPlaceholder)?.n_a ?? 0})
- + B ({(umapResult ?? umapPlaceholder)?.n_b ?? 0})
{t("compare.umapDesc")} @@ -2651,7 +2163,7 @@ the Free Software Foundation, version 3 only. --> transition-transform duration-150 shrink-0 {sleepExpanded ? 'rotate-90' : ''}"> - {t("sleep.title")} @@ -2667,28 +2179,28 @@ the Free Software Foundation, version 3 only. --> ] as item} {#if item.sa}
- {item.label} + bg-surface-1 px-3 py-2 flex flex-col gap-1"> + {item.label}
- Efficiency - + Efficiency + {item.sa.efficiency.toFixed(0)}%
- Onset - {item.sa.onsetLatencyMin.toFixed(0)}m + Onset + {item.sa.onsetLatencyMin.toFixed(0)}m
{#if item.sa.remLatencyMin >= 0}
- → REM - {item.sa.remLatencyMin.toFixed(0)}m + → REM + {item.sa.remLatencyMin.toFixed(0)}m
{/if}
- Awakenings - {item.sa.awakenings} + Awakenings + {item.sa.awakenings}
@@ -2699,9 +2211,9 @@ the Free Software Foundation, version 3 only. --> {#if sleepA}
- A + A
+ bg-surface-1 p-2">
@@ -2710,9 +2222,9 @@ the Free Software Foundation, version 3 only. --> {#if sleepB}
- B + B
+ bg-surface-1 p-2">
@@ -2729,29 +2241,29 @@ the Free Software Foundation, version 3 only. --> { key: "sleep.n3", a: sleepA.summary.n3_epochs, b: sleepB.summary.n3_epochs, color: "#6366f1" }, ]}
+ bg-surface-1 overflow-hidden">
- {t("sleep.title")} - A - B - {t("compare.diff")} + {t("sleep.title")} + A + B + {t("compare.diff")}
{#each stages as st} {@const aPct = sleepA.summary.total_epochs > 0 ? (st.a / sleepA.summary.total_epochs * 100) : 0} {@const bPct = sleepB.summary.total_epochs > 0 ? (st.b / sleepB.summary.total_epochs * 100) : 0} {@const d = aPct - bPct}
- {t(st.key)} + {t(st.key)}
- {aPct.toFixed(0)}% - {bPct.toFixed(0)}% - + {aPct.toFixed(0)}% + {bPct.toFixed(0)}% + {Math.abs(d) < 0.5 ? "—" : `${d > 0 ? "+" : ""}${d.toFixed(0)}`}
diff --git a/src/routes/downloads/+page.svelte b/src/routes/downloads/+page.svelte new file mode 100644 index 000000000..d8ca6e4b0 --- /dev/null +++ b/src/routes/downloads/+page.svelte @@ -0,0 +1,160 @@ + + + + +
+
+
+ + Total download size · {items.length} {items.length === 1 ? "item" : "items"} + + + {loading ? t("downloads.loading") : fmtSize(totalDownloadsSizeGb)} + +
+
+ +
+ {#if loading} +

{t("downloads.loading")}

+ {:else if items.length === 0} +

{t("downloads.empty")}

+ {:else} +
+ {#each items as item (item.filename)} +
+
+
+

{item.filename}

+

{item.description}

+

+ {item.quant} · {fmtSize(item.size_gb)}{item.shard_count > 1 ? ` (${item.shard_count} parts)` : ""} · {statusLabel(item.state)}{item.shard_count > 1 && item.current_shard > 0 ? ` — part ${item.current_shard}/${item.shard_count}` : ""} +

+

{t("downloads.initiatedAt")}: {fmtInitiated(item.initiated_at_unix)}

+ {#if item.status_msg} +

{item.status_msg}

+ {/if} +
+ +
+ {#if item.state === "downloading"} + + + {:else if item.state === "paused"} + + + {:else} + + {/if} +
+
+ + {#if item.state === "downloading" || item.state === "paused"} +
+
+
+ {/if} +
+ {/each} +
+ {/if} +
+ +
diff --git a/src/routes/focus-timer/+page.svelte b/src/routes/focus-timer/+page.svelte index a14c0ec8b..3a9f0549c 100644 --- a/src/routes/focus-timer/+page.svelte +++ b/src/routes/focus-timer/+page.svelte @@ -8,297 +8,380 @@ the Free Software Foundation, version 3 only. --> Configurable work/break timer with automatic EEG labelling. --> -
- - -
- - - {t("focusTimer.title")} - - {#if sessionsDone > 0} - - {t("focusTimer.sessionCount", { n: sessionsDone })} - - {/if} - - - -
+
-
+
-
{phaseLabel}
@@ -365,21 +448,21 @@ the Free Software Foundation, version 3 only. -->
{#if phase === "idle"} - {:else} - - {/if} @@ -390,7 +473,7 @@ the Free Software Foundation, version 3 only. --> onclick={openLabel} class="flex items-center gap-1.5 rounded-lg border border-border dark:border-white/[0.08] bg-muted/20 hover:bg-muted/40 - px-3.5 py-1.5 text-[0.72rem] font-medium text-muted-foreground + px-3.5 py-1.5 text-ui-md font-medium text-muted-foreground hover:text-foreground transition-colors" >
-

+

Presets

-
- {#each (["pomodoro", "deepWork", "shortFocus"] as const) as p} - - {/each} -
+ p === selectedPreset) ?? "pomodoro"} + onselect={(p) => applyPreset(p)} + labelFn={(p) => t(`focusTimer.preset.${p}`)} + class={phase !== "idle" ? "opacity-40 pointer-events-none" : ""} + />
-

+

{t("focusTimer.preset.custom")}

- + selectedPreset = "custom"} - class="w-full px-2 py-1 text-[0.78rem] rounded-md + class="w-full px-2 py-1 text-ui-lg rounded-md border border-border dark:border-white/[0.08] bg-background text-foreground - focus:outline-none focus:ring-1 focus:ring-blue-500/40 + focus:outline-none focus:ring-1 focus:ring-ring/40 disabled:opacity-40" />
- + selectedPreset = "custom"} - class="w-full px-2 py-1 text-[0.78rem] rounded-md + class="w-full px-2 py-1 text-ui-lg rounded-md border border-border dark:border-white/[0.08] bg-background text-foreground - focus:outline-none focus:ring-1 focus:ring-blue-500/40 + focus:outline-none focus:ring-1 focus:ring-ring/40 disabled:opacity-40" />
- + selectedPreset = "custom"} - class="w-full px-2 py-1 text-[0.78rem] rounded-md + class="w-full px-2 py-1 text-ui-lg rounded-md border border-border dark:border-white/[0.08] bg-background text-foreground - focus:outline-none focus:ring-1 focus:ring-blue-500/40 + focus:outline-none focus:ring-1 focus:ring-ring/40 disabled:opacity-40" />
-
@@ -498,18 +572,18 @@ the Free Software Foundation, version 3 only. -->
+ dark:border-white/[0.06] bg-muted/20 px-3 py-2.5">
-
@@ -517,18 +591,18 @@ the Free Software Foundation, version 3 only. -->
+ dark:border-white/[0.06] bg-muted/20 px-3 py-2.5">
-
@@ -538,12 +612,154 @@ the Free Software Foundation, version 3 only. --> {#if sessionsDone > 0 && phase === "idle"} {/if} + +
+ + + + + {#if logOpen} + + {#if cyclesDone > 0 || sessionLog.length > 0} +
+ +
+ + {t("focusTimer.log.focusTime")} + + + {fmtDuration(focusSecs)} + +
+ +
+ + {t("focusTimer.log.breakTime")} + + + {fmtDuration(breakSecs)} + +
+ +
+ + {t("focusTimer.log.totalTime")} + + + {fmtDuration(logTotalSecs)} + +
+
+ {/if} + + +
+ {#if sessionLog.length === 0} +

+ {t("focusTimer.log.empty")} +

+ {:else} + + {#each [...sessionLog].reverse() as entry, ri} + {@const isWork = entry.type === "work"} + {@const isLongBreak = entry.type === "longBreak"} +
+ + + + + + {isWork + ? t("focusTimer.log.work") + : isLongBreak + ? t("focusTimer.log.longBreak") + : t("focusTimer.log.break")} + + + + {fmtDuration(entry.durationSecs)} + + + + {fmtTime(entry.startUtc)} → {fmtTime(entry.completedAt)} + +
+ {/each} + {/if} +
+ + + {#if sessionLog.length > 0} +
+ +
+ {/if} + {/if} +
+
diff --git a/src/routes/help/+page.svelte b/src/routes/help/+page.svelte index 9c7b28b01..ff892eefe 100644 --- a/src/routes/help/+page.svelte +++ b/src/routes/help/+page.svelte @@ -4,399 +4,431 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> - + -
- - -
- - - - - {#if searchQuery} - - {/if} -
+ + +
- -
-
+ +
+ + + + + + + +
+ + {#if searchQuery.trim()} + + {#if searchResults.length === 0} +
+ + + +

+ {t("help.searchNoResults").replace("{query}", searchQuery.trim())} +

+
+ {:else} +
+

+ {searchResults.length} {searchResults.length === 1 ? "result" : "results"} +

+ {#each searchResults as item} + {@const tLabel = helpTabLabel(item.tab)} + + {/each} +
+ {/if} + {:else} + + {#if tab === "dashboard"} + + {:else if tab === "electrodes"} + + {:else if tab === "settings"} + + {:else if tab === "windows"} + + {:else if tab === "api"} + + {:else if tab === "tts"} + + {:else if tab === "llm"} + + {:else if tab === "hooks"} + + {:else if tab === "privacy"} + + {:else if tab === "references"} + + {:else} + + {/if} + {/if} + +
-
- - - - v{appVersion} - - - {t("settings.license")} - -
-
- - {#if searchQuery.trim()} - - {#if searchResults.length === 0} -
- - - -

- {t("help.searchNoResults").replace("{query}", searchQuery.trim())} -

-
- {:else} -
-

- {searchResults.length} {searchResults.length === 1 ? "result" : "results"} -

- {#each searchResults as item} - {@const tabLabel = helpTabLabel(item.tab)} - {@const title = t(item.titleKey as Parameters[0])} - {@const body = t(item.bodyKey as Parameters[0])} - - {/each} -
- {/if} - {:else} - - {#if tab === "dashboard"} - - {:else if tab === "electrodes"} - - {:else if tab === "settings"} - - {:else if tab === "windows"} - - {:else if tab === "api"} - - {:else if tab === "tts"} - - {:else if tab === "privacy"} - - {:else if tab === "references"} - - {:else} - - {/if} - {/if} - - +
diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte index 345ec3bae..8e37c7bd8 100644 --- a/src/routes/search/+page.svelte +++ b/src/routes/search/+page.svelte @@ -5,721 +5,1475 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 only. --> -
- - -
- - - - - - {t("search.title")} - - -
- -
- -
- -
+
- -
+ +
+
{#if mode === "eeg" && result} - + {t("search.resultSummary", { queries: result.query_count, k: result.k, days: result.searched_days.length })} {:else if mode === "text" && textSearched} - + {textFiltered.length} / {textResults.length} {t("search.textResultsCount")} {:else if mode === "interactive" && ixSearched} - + {t("search.interactiveNodeCount", { n: ixNodes.length, e: ixEdges.length })} + {:else if mode === "images" && imgSearched} + + {imgResults.length} {t("search.imageResultsCount")} + {/if}
- - - {#if mode === "eeg" && searching && streamTotal > 0}
@@ -732,17 +1486,17 @@ the Free Software Foundation, version 3 only. -->
+ bg-muted/20 dark:bg-white/[0.02]"> {#if mode === "eeg"}
- + {t("search.last")} - {#each PRESETS as [label, mins]} + {#each PRESETS as [label, mins] (mins)}
@@ -765,35 +1521,52 @@ the Free Software Foundation, version 3 only. -->
- k + k
- ef + ef
+ {#if deviceList.length > 0} + + {/if} {#if error} - {error} + {error} {:else} {/if} {#if searching} - {/if} - step.set(Number((e.target as HTMLInputElement).value))} - class="w-9 rounded border border-border dark:border-white/[0.1] - bg-background px-0.5 py-0.5 text-[0.68rem] text-center font-mono + class="w-11 rounded border border-border dark:border-white/[0.1] + bg-background px-0.5 py-0.5 text-ui-base text-center font-mono focus:outline-none focus:ring-1 focus:ring-ring" />
{/each} + + + + {#if ixShowAdvanced} + +
+ 6 + + {t("search.snrFilterHint")} + +
+ + + {#if deviceList.length > 0} +
+ 7 + {t("search.deviceFilterLabel")} + {t("search.deviceFilterHint")} + +
+ {/if} + + +
+ 8 + {t("search.dateRangeLabel")} +
+ { const v = (e.target as HTMLInputElement).value; ixFilterStartUtc = v ? Math.floor(new Date(v).getTime() / 1000) : undefined; }} + class="rounded border border-border dark:border-white/[0.1] bg-background px-1 py-0.5 text-ui-xs + focus:outline-none focus:ring-1 focus:ring-ring flex-1 min-w-0" /> + + { const v = (e.target as HTMLInputElement).value; ixFilterEndUtc = v ? Math.floor(new Date(v).getTime() / 1000) : undefined; }} + class="rounded border border-border dark:border-white/[0.1] bg-background px-1 py-0.5 text-ui-xs + focus:outline-none focus:ring-1 focus:ring-ring flex-1 min-w-0" /> +
+ +
+ {#each [ + { label: "24h", mins: 1440 }, + { label: "7d", mins: 10080 }, + { label: "30d", mins: 43200 }, + ] as p} + + {/each} + {#if ixFilterStartUtc || ixFilterEndUtc} + + {/if} +
+
+ + +
+ 9 + {t("search.rankByLabel")} + {t("search.rankByHint")} + +
+ + + {#if ixPerf} +
+ {t("search.perfEmbed")} {ixPerf.embed_ms}ms + {t("search.perfGraph")} {ixPerf.graph_ms}ms + {t("search.perfTotal")} {ixPerf.total_ms}ms + {ixPerf.node_count} {t("search.perfNodes")} + {ixPerf.edge_count} {t("search.perfEdges")} + {t("search.perfCpu")} {ixPerf.cpu_usage_pct?.toFixed(0)}% + {t("search.perfMem")} {ixPerf.mem_used_mb}/{ixPerf.mem_total_mb}MB +
+ {/if} + + {/if} + + + {#if ixSessions.length > 0} +
+
+ {t("search.sessionsTitle")} + +
+ + {#if ixSessions.length > 1} + {@const maxEng = Math.max(...ixSessions.map(s => s.avg_engagement ?? 0), 0.01)} + {@const pts = ixSessions.map((s, i) => `${(i / (ixSessions.length - 1)) * 120},${40 - (s.avg_engagement / maxEng) * 36}`).join(" ")} + + + {#each ixSessions as s, i} + {@const x = (i / (ixSessions.length - 1)) * 120} + {@const y = 40 - (s.avg_engagement / maxEng) * 36} + + {/each} + + {/if} + + {#each ixSessions as s} + {@const engPct = Math.round(Math.min(1, s.avg_engagement ?? 0) * 100)} + {@const snrPct = Math.round(Math.min(1, (s.avg_snr ?? 0) / 20) * 100)} +
+ {#if s.best}{/if} + {s.session_id} + {s.epoch_count}ep + {Math.round(s.duration_secs / 60)}m + +
+
+
+ {s.avg_engagement?.toFixed(2)} + +
+
+
+ {s.avg_snr?.toFixed(1)} +
+ {/each} +
+ {/if} +
{#if error} - {error} + {error} {:else} - + {t("search.interactiveCmdEnter")} {/if}
- {:else} + {:else if mode === "text"}
-